authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 00:08:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 00:08:32-07:00
log79c57170c7fb9cc1844427aed93c5f297fb110b5
tree9566c4e17cc4887f8485ad95363ce69e0988daf5
parent3900c45f25049ea8df359736f0ea8ad97bd3624f
signature Signed by PGP key B5690EEEBB952194

[codex] Make settings environment-scoped by default (#3216)

Co-authored-by: Julius Marminge <julius@mac.lan>

30 files changed, 254 insertions(+), 177 deletions(-)

apps/mobile/src/state/entities.ts+2-3
......@@ -12,8 +12,7 @@ import type {
1212import { Atom } from "effect/unstable/reactivity";
1313
1414import { environmentProjects } from "./projects";
15import { environmentServerConfigsAtom } from "./server";
16import { environmentSession } from "./session";
15import { environmentServerConfigsAtom, serverEnvironment } from "./server";
1716import { environmentThreadShells } from "./threads";
1817
1918const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
......@@ -50,7 +49,7 @@ export function useEnvironmentServerConfig(
5049 return useAtomValue(
5150 environmentId === null
5251 ? EMPTY_SERVER_CONFIG_ATOM
53 : environmentSession.configValueAtom(environmentId),
52 : serverEnvironment.configValueAtom(environmentId),
5453 );
5554}
5655
apps/mobile/src/state/presentation.ts+2-2
......@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
55import { Atom } from "effect/unstable/reactivity";
66
77import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";
8import { serverEnvironment } from "./server";
99
1010export const environmentPresentations = createEnvironmentPresentationAtoms({
1111 catalogValueAtom: environmentCatalog.catalogValueAtom,
1212 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,
13 serverConfigValueAtom: serverEnvironment.configValueAtom,
1414});
1515
1616const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(
apps/mobile/src/state/server.ts+2-2
......@@ -6,9 +6,9 @@ import { connectionAtomRuntime } from "../connection/runtime";
66import { environmentSession } from "./session";
77
88export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
9 initialConfigValueAtom: environmentSession.configValueAtom,
9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
1010});
1111export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
1212 catalogValueAtom: environmentCatalog.catalogValueAtom,
13 configValueAtom: serverEnvironment.configValueAtom,
13 serverConfigValueAtom: serverEnvironment.configValueAtom,
1414});
apps/web/src/components/ChatView.tsx+8-4
......@@ -138,7 +138,7 @@ import {
138138} from "~/projectScripts";
139139import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
140140import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
141import { useSettings } from "../hooks/useSettings";
141import { useEnvironmentSettings } from "../hooks/useSettings";
142142import { resolveAppModelSelectionForInstance } from "../modelSelection";
143143import { getTerminalFocusOwner } from "../lib/terminalFocus";
144144import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
......@@ -1027,7 +1027,7 @@ function ChatViewContent(props: ChatViewProps) {
10271027 const activeThreadLastVisitedAt = useUiStateStore((store) =>
10281028 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
10291029 );
1030 const settings = useSettings();
1030 const settings = useEnvironmentSettings(environmentId);
10311031 const setStickyComposerModelSelection = useComposerDraftStore(
10321032 (store) => store.setStickyModelSelection,
10331033 );
......@@ -1417,7 +1417,7 @@ function ChatViewContent(props: ChatViewProps) {
14171417 },
14181418 [retryEnvironment],
14191419 );
1420 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
1420 const projectGroupingSettings = selectProjectGroupingSettings(settings);
14211421 const logicalProjectEnvironments = useMemo(() => {
14221422 if (!activeProject) return [];
14231423 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
......@@ -1586,7 +1586,11 @@ function ChatViewContent(props: ChatViewProps) {
15861586 selectedProvider: selectedProviderByThreadId,
15871587 threadProvider,
15881588 });
1589 const serverConfig = activeEnvironment?.serverConfig ?? primaryEnvironment?.serverConfig ?? null;
1589 // Once a thread selects an environment, never substitute the primary
1590 // environment's config while the selected environment is still loading.
1591 const serverConfig = activeThread
1592 ? (activeEnvironment?.serverConfig ?? null)
1593 : (primaryEnvironment?.serverConfig ?? null);
15901594 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
15911595 const versionMismatchDismissKey =
15921596 versionMismatch && activeThread
apps/web/src/components/CommandPalette.tsx+14-38
......@@ -45,7 +45,7 @@ import {
4545import { useAtomValue } from "@effect/atom-react";
4646import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
4747import { useHandleNewThread } from "../hooks/useHandleNewThread";
48import { useSettings } from "../hooks/useSettings";
48import { useClientSettings } from "../hooks/useSettings";
4949import { readLocalApi } from "../localApi";
5050import { filesystemEnvironment } from "../state/filesystem";
5151import { projectEnvironment } from "../state/projects";
......@@ -447,7 +447,7 @@ function OpenCommandPaletteDialog(props: {
447447 const deferredQuery = useDeferredValue(query);
448448 const isActionsOnly = deferredQuery.startsWith(">");
449449 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
450 const settings = useSettings();
450 const clientSettings = useClientSettings();
451451 const createProject = useAtomCommand(projectEnvironment.create, {
452452 reportFailure: false,
453453 });
......@@ -524,16 +524,14 @@ function OpenCommandPaletteDialog(props: {
524524 const environment = environments.find(
525525 (candidate) => candidate.environmentId === environmentId,
526526 );
527 const environmentSettings =
528 environment?.serverConfig?.settings ??
529 (environmentId === primaryEnvironmentId ? settings : null);
527 const environmentSettings = environment?.serverConfig?.settings ?? null;
530528 const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? "";
531529 if (baseDirectory.length === 0) {
532530 return "~/";
533531 }
534532 return ensureBrowseDirectoryPath(baseDirectory);
535533 },
536 [environments, primaryEnvironmentId, settings],
534 [environments],
537535 );
538536
539537 const projectCwdById = useMemo(
......@@ -589,7 +587,7 @@ function OpenCommandPaletteDialog(props: {
589587 const latestThread = getLatestThreadForProject(
590588 threads.filter((thread) => thread.environmentId === project.environmentId),
591589 project.id,
592 settings.sidebarThreadSortOrder,
590 clientSettings.sidebarThreadSortOrder,
593591 );
594592 if (latestThread) {
595593 await navigate({
......@@ -601,17 +599,9 @@ function OpenCommandPaletteDialog(props: {
601599 return;
602600 }
603601
604 await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
605 envMode: settings.defaultThreadEnvMode,
606 });
602 await handleNewThread(scopeProjectRef(project.environmentId, project.id));
607603 },
608 [
609 handleNewThread,
610 navigate,
611 settings.defaultThreadEnvMode,
612 settings.sidebarThreadSortOrder,
613 threads,
614 ],
604 [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads],
615605 );
616606
617607 const projectSearchItems = useMemo(
......@@ -650,21 +640,13 @@ function OpenCommandPaletteDialog(props: {
650640 activeDraftThread,
651641 activeThread: activeThread ?? undefined,
652642 defaultProjectRef,
653 defaultThreadEnvMode: settings.defaultThreadEnvMode,
654643 handleNewThread,
655644 },
656645 scopeProjectRef(project.environmentId, project.id),
657646 );
658647 },
659648 }),
660 [
661 activeDraftThread,
662 activeThread,
663 defaultProjectRef,
664 handleNewThread,
665 projects,
666 settings.defaultThreadEnvMode,
667 ],
649 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
668650 );
669651
670652 const allThreadItems = useMemo(
......@@ -673,7 +655,7 @@ function OpenCommandPaletteDialog(props: {
673655 threads,
674656 ...(activeThreadId ? { activeThreadId } : {}),
675657 projectTitleById,
676 sortOrder: settings.sidebarThreadSortOrder,
658 sortOrder: clientSettings.sidebarThreadSortOrder,
677659 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
678660 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
679661 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
......@@ -684,7 +666,7 @@ function OpenCommandPaletteDialog(props: {
684666 });
685667 },
686668 }),
687 [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads],
669 [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads],
688670 );
689671 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
690672
......@@ -956,7 +938,6 @@ function OpenCommandPaletteDialog(props: {
956938 activeDraftThread,
957939 activeThread: activeThread ?? undefined,
958940 defaultProjectRef,
959 defaultThreadEnvMode: settings.defaultThreadEnvMode,
960941 handleNewThread,
961942 });
962943 },
......@@ -1072,7 +1053,7 @@ function OpenCommandPaletteDialog(props: {
10721053 const latestThread = getLatestThreadForProject(
10731054 threads.filter((thread) => thread.environmentId === existing.environmentId),
10741055 existing.id,
1075 settings.sidebarThreadSortOrder,
1056 clientSettings.sidebarThreadSortOrder,
10761057 );
10771058 if (latestThread) {
10781059 await navigate({
......@@ -1083,9 +1064,7 @@ function OpenCommandPaletteDialog(props: {
10831064 });
10841065 } else {
10851066 const navigationResult = await settlePromise(() =>
1086 handleNewThread(scopeProjectRef(existing.environmentId, existing.id), {
1087 envMode: settings.defaultThreadEnvMode,
1088 }),
1067 handleNewThread(scopeProjectRef(existing.environmentId, existing.id)),
10891068 );
10901069 if (navigationResult._tag === "Failure") {
10911070 const error = squashAtomCommandFailure(navigationResult);
......@@ -1132,9 +1111,7 @@ function OpenCommandPaletteDialog(props: {
11321111 }
11331112
11341113 const navigationResult = await settlePromise(() =>
1135 handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), {
1136 envMode: settings.defaultThreadEnvMode,
1137 }),
1114 handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)),
11381115 );
11391116 if (navigationResult._tag === "Failure") {
11401117 const error = squashAtomCommandFailure(navigationResult);
......@@ -1158,8 +1135,7 @@ function OpenCommandPaletteDialog(props: {
11581135 navigate,
11591136 projects,
11601137 setOpen,
1161 settings.defaultThreadEnvMode,
1162 settings.sidebarThreadSortOrder,
1138 clientSettings.sidebarThreadSortOrder,
11631139 threads,
11641140 ],
11651141 );
apps/web/src/components/DiffPanel.tsx+2-2
......@@ -34,7 +34,7 @@ import {
3434import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
3535import { useProject, useThread } from "../state/entities";
3636import { resolveThreadRouteRef } from "../threadRoutes";
37import { useSettings } from "../hooks/useSettings";
37import { useClientSettings } from "../hooks/useSettings";
3838import { formatShortTimestamp } from "../timestampFormat";
3939import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
4040import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff";
......@@ -183,7 +183,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
183183
184184export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) {
185185 const { resolvedTheme } = useTheme();
186 const settings = useSettings();
186 const settings = useClientSettings();
187187 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
188188 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
189189 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
apps/web/src/components/Sidebar.tsx+21-20
......@@ -41,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
4141import { CSS } from "@dnd-kit/utilities";
4242import {
4343 type ContextMenuItem,
44 DEFAULT_SERVER_SETTINGS,
4445 ProjectId,
4546 type ScopedThreadRef,
4647 type ResolvedKeybindingsConfig,
4748 type SidebarProjectGroupingMode,
48 type ThreadEnvMode,
4949 ThreadId,
5050} from "@t3tools/contracts";
5151import {
......@@ -77,6 +77,7 @@ import {
7777 readThreadShell,
7878 useProject,
7979 useProjects,
80 useServerConfigs,
8081 useThreadShells,
8182 useThreadShellsForProjectRefs,
8283} from "../state/entities";
......@@ -199,7 +200,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
199200import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
200201import { useIsMobile } from "~/hooks/useMediaQuery";
201202import { CommandDialogTrigger } from "./ui/command";
202import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
203import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
203204import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";
204205import {
205206 derivePhysicalProjectKey,
......@@ -1084,19 +1085,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
10841085 isManualProjectSorting,
10851086 dragHandleProps,
10861087 } = props;
1087 const threadSortOrder = useSettings<SidebarThreadSortOrder>(
1088 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
10881089 (settings) => settings.sidebarThreadSortOrder,
10891090 );
1090 const appSettingsConfirmThreadDelete = useSettings<boolean>(
1091 const appSettingsConfirmThreadDelete = useClientSettings<boolean>(
10911092 (settings) => settings.confirmThreadDelete,
10921093 );
1093 const appSettingsConfirmThreadArchive = useSettings<boolean>(
1094 const appSettingsConfirmThreadArchive = useClientSettings<boolean>(
10941095 (settings) => settings.confirmThreadArchive,
10951096 );
1096 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
1097 (settings) => settings.defaultThreadEnvMode,
1098 );
1099 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
1097 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1098 const serverConfigs = useServerConfigs();
11001099 const deleteProject = useAtomCommand(projectEnvironment.delete, {
11011100 reportFailure: false,
11021101 });
......@@ -1106,8 +1105,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11061105 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
11071106 reportFailure: false,
11081107 });
1109 const updateSettings = useUpdateSettings();
1110 const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>(
1108 const updateSettings = useUpdateClientSettings();
1109 const sidebarThreadPreviewCount = useClientSettings<SidebarThreadPreviewCount>(
11111110 (settings) => settings.sidebarThreadPreviewCount,
11121111 );
11131112 const router = useRouter();
......@@ -1840,7 +1839,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18401839 const seedContext = resolveSidebarNewThreadSeedContext({
18411840 projectId: member.id,
18421841 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1843 defaultEnvMode: defaultThreadEnvMode,
1842 defaultEnvMode:
1843 serverConfigs.get(member.environmentId)?.settings.defaultThreadEnvMode ??
1844 DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
18441845 }),
18451846 activeThread:
18461847 currentActiveThread && currentActiveThread.projectId === member.id
......@@ -1889,7 +1890,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18891890 }
18901891 })();
18911892 },
1892 [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile],
1893 [handleNewThread, isMobile, router, serverConfigs, setOpenMobile],
18931894 );
18941895
18951896 const handleCreateThreadClick = useCallback(
......@@ -2745,7 +2746,7 @@ interface SidebarProjectsContentProps {
27452746 threadSortOrder: SidebarThreadSortOrder;
27462747 projectGroupingMode: SidebarProjectGroupingMode;
27472748 threadPreviewCount: SidebarThreadPreviewCount;
2748 updateSettings: ReturnType<typeof useUpdateSettings>;
2749 updateSettings: ReturnType<typeof useUpdateClientSettings>;
27492750 openAddProject: () => void;
27502751 isManualProjectSorting: boolean;
27512752 projectDnDSensors: ReturnType<typeof useSensors>;
......@@ -3014,12 +3015,12 @@ export default function Sidebar() {
30143015 const navigate = useNavigate();
30153016 const pathname = useLocation({ select: (loc) => loc.pathname });
30163017 const isOnSettings = pathname.startsWith("/settings");
3017 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);
3018 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);
3019 const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode);
3020 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
3021 const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount);
3022 const updateSettings = useUpdateSettings();
3018 const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
3019 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
3020 const sidebarProjectGroupingMode = useClientSettings((s) => s.sidebarProjectGroupingMode);
3021 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3022 const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount);
3023 const updateSettings = useUpdateClientSettings();
30233024 const handleNewThread = useNewThreadHandler();
30243025 const { archiveThread, deleteThread } = useThreadActions();
30253026 const { isMobile, setOpenMobile } = useSidebar();
apps/web/src/components/chat/ModelPickerContent.tsx+3-3
......@@ -19,7 +19,7 @@ import {
1919 resolveShortcutCommand,
2020 shortcutLabelForCommand,
2121} from "../../keybindings";
22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
22import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
2323import { cn } from "~/lib/utils";
2424import { TooltipProvider } from "../ui/tooltip";
2525import {
......@@ -102,7 +102,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
102102 const searchInputRef = useRef<HTMLInputElement>(null);
103103 const modelListRef = useRef<LegendListRef | null>(null);
104104 const highlightedModelKeyRef = useRef<string | null>(null);
105 const favorites = useSettings((s) => s.favorites ?? []);
105 const favorites = useClientSettings((s) => s.favorites ?? []);
106106 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
107107 () => {
108108 if (props.lockedProvider !== null) {
......@@ -117,7 +117,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
117117 () => providedKeybindings ?? [],
118118 [providedKeybindings],
119119 );
120 const updateSettings = useUpdateSettings();
120 const updateSettings = useUpdateClientSettings();
121121
122122 const focusSearchInput = useCallback(() => {
123123 searchInputRef.current?.focus({ preventScroll: true });
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+3-3
......@@ -9,7 +9,7 @@ import {
99 type ProviderInstanceConfig,
1010} from "@t3tools/contracts";
1111
12import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
12import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
1313import { cn } from "../../lib/utils";
1414import { normalizeProviderAccentColor } from "../../providerInstances";
1515import { Button } from "../ui/button";
......@@ -114,8 +114,8 @@ interface AddProviderInstanceDialogProps {
114114}
115115
116116export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
117 const settings = useSettings();
118 const updateSettings = useUpdateSettings();
117 const settings = usePrimarySettings();
118 const updateSettings = useUpdatePrimarySettings();
119119
120120 const [wizardStep, setWizardStep] = useState(0);
121121 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
apps/web/src/components/settings/SettingsPanels.tsx+7-7
......@@ -36,7 +36,7 @@ import { TraitsPicker } from "../chat/TraitsPicker";
3636import { isElectron } from "../../env";
3737import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
3838import { useTheme } from "../../hooks/useTheme";
39import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
39import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
4040import { useThreadActions } from "../../hooks/useThreadActions";
4141import { useDesktopUpdateState } from "../../state/desktopUpdate";
4242import {
......@@ -373,8 +373,8 @@ function AboutVersionSection() {
373373
374374export function useSettingsRestore(onRestored?: () => void) {
375375 const { theme, setTheme } = useTheme();
376 const settings = useSettings();
377 const updateSettings = useUpdateSettings();
376 const settings = usePrimarySettings();
377 const updateSettings = useUpdatePrimarySettings();
378378
379379 const isGitWritingModelDirty = !Equal.equals(
380380 settings.textGenerationModelSelection ?? null,
......@@ -479,8 +479,8 @@ export function useSettingsRestore(onRestored?: () => void) {
479479
480480export function GeneralSettingsPanel() {
481481 const { theme, setTheme } = useTheme();
482 const settings = useSettings();
483 const updateSettings = useUpdateSettings();
482 const settings = usePrimarySettings();
483 const updateSettings = useUpdatePrimarySettings();
484484 const observability = useAtomValue(primaryServerObservabilityAtom);
485485 const serverProviders = useAtomValue(primaryServerProvidersAtom);
486486 const diagnosticsDescription = formatDiagnosticsDescription({
......@@ -977,8 +977,8 @@ export function GeneralSettingsPanel() {
977977}
978978
979979export function ProviderSettingsPanel() {
980 const settings = useSettings();
981 const updateSettings = useUpdateSettings();
980 const settings = usePrimarySettings();
981 const updateSettings = useUpdatePrimarySettings();
982982 const serverProviders = useAtomValue(primaryServerProvidersAtom);
983983 const primaryEnvironment = usePrimaryEnvironment();
984984 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
apps/web/src/components/settings/SourceControlSettings.tsx+5-3
......@@ -12,7 +12,7 @@ import type {
1212} from "@t3tools/contracts";
1313import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1414
15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
15import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
1616import { cn } from "../../lib/utils";
1717import { usePrimaryEnvironment } from "../../state/environments";
1818import { useEnvironmentQuery } from "../../state/query";
......@@ -291,8 +291,10 @@ function DiscoveryItemRow({
291291}
292292
293293function GitFetchIntervalSettings() {
294 const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval);
295 const updateSettings = useUpdateSettings();
294 const automaticGitFetchInterval = usePrimarySettings(
295 (settings) => settings.automaticGitFetchInterval,
296 );
297 const updateSettings = useUpdatePrimarySettings();
296298 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);
297299 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(
298300 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
apps/web/src/hooks/useHandleNewThread.ts+14-10
......@@ -3,7 +3,11 @@ import {
33 scopeProjectRef,
44 scopeThreadRef,
55} from "@t3tools/client-runtime/environment";
6import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts";
6import {
7 DEFAULT_RUNTIME_MODE,
8 DEFAULT_SERVER_SETTINGS,
9 type ScopedProjectRef,
10} from "@t3tools/contracts";
711import { useParams, useRouter } from "@tanstack/react-router";
812import { useCallback, useMemo } from "react";
913import {
......@@ -19,18 +23,16 @@ import {
1923 getProjectOrderKey,
2024 selectProjectGroupingSettings,
2125} from "../logicalProject";
22import { readThreadShell, useProjects, useThread } from "../state/entities";
26import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities";
2327import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
2428import { resolveThreadRouteTarget } from "../threadRoutes";
2529import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
26import { useSettings } from "./useSettings";
30import { useClientSettings } from "./useSettings";
2731
2832export function useNewThreadHandler() {
2933 const projects = useProjects();
30 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
31 const newWorktreesStartFromOrigin = useSettings(
32 (settings) => settings.newWorktreesStartFromOrigin,
33 );
34 const serverConfigs = useServerConfigs();
35 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3436 const router = useRouter();
3537 const getCurrentRouteTarget = useCallback(() => {
3638 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
......@@ -61,6 +63,8 @@ export function useNewThreadHandler() {
6163 candidate.id === projectRef.projectId &&
6264 candidate.environmentId === projectRef.environmentId,
6365 );
66 const environmentSettings =
67 serverConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
6468 const logicalProjectKey = project
6569 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
6670 : scopedProjectKey(projectRef);
......@@ -155,7 +159,7 @@ export function useNewThreadHandler() {
155159 const draftId = newDraftId();
156160 const threadId = newThreadId();
157161 const createdAt = new Date().toISOString();
158 const initialEnvMode = options?.envMode ?? "local";
162 const initialEnvMode = options?.envMode ?? environmentSettings.defaultThreadEnvMode;
159163 return (async () => {
160164 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
161165 threadId,
......@@ -167,7 +171,7 @@ export function useNewThreadHandler() {
167171 options?.startFromOrigin ??
168172 resolveNewDraftStartFromOrigin({
169173 envMode: initialEnvMode,
170 newWorktreesStartFromOrigin,
174 newWorktreesStartFromOrigin: environmentSettings.newWorktreesStartFromOrigin,
171175 }),
172176 runtimeMode: DEFAULT_RUNTIME_MODE,
173177 });
......@@ -179,7 +183,7 @@ export function useNewThreadHandler() {
179183 });
180184 })();
181185 },
182 [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects],
186 [getCurrentRouteTarget, projectGroupingSettings, projects, router, serverConfigs],
183187 );
184188}
185189
apps/web/src/hooks/useSettings.test.ts created+37
......@@ -0,0 +1,37 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
7import { describe, expect, it } from "vite-plus/test";
8
9import { mergeEnvironmentSettings } from "./useSettings";
10
11describe("mergeEnvironmentSettings", () => {
12 it("combines the selected environment's server settings with client preferences", () => {
13 const serverSettings = {
14 ...DEFAULT_SERVER_SETTINGS,
15 providerInstances: {
16 [ProviderInstanceId.make("codex_remote")]: {
17 driver: ProviderDriverKind.make("codex"),
18 enabled: true,
19 },
20 },
21 };
22 const clientSettings = {
23 ...DEFAULT_CLIENT_SETTINGS,
24 favorites: [
25 {
26 provider: ProviderInstanceId.make("codex_remote"),
27 model: "gpt-5.4",
28 },
29 ],
30 };
31
32 const settings = mergeEnvironmentSettings(serverSettings, clientSettings);
33
34 expect(settings.providerInstances).toBe(serverSettings.providerInstances);
35 expect(settings.favorites).toBe(clientSettings.favorites);
36 });
37});
apps/web/src/hooks/useSettings.ts+72-23
......@@ -1,22 +1,27 @@
11/**
2 * Unified settings hook.
2 * Environment-scoped settings hooks.
33 *
44 * Abstracts the split between server-authoritative settings (persisted in
55 * `settings.json` on the server, fetched via `server.getConfig`) and
66 * client-only settings (persisted in localStorage).
77 *
8 * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to
9 * write. The hook transparently routes reads/writes to the correct backing
10 * store.
8 * Live server settings always require an environment id. Primary-environment
9 * access is intentionally named as such so environment-sensitive consumers
10 * cannot silently read the wrong server's settings.
1111 */
1212import { useCallback, useMemo, useSyncExternalStore } from "react";
1313import { useAtomValue } from "@effect/atom-react";
14import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
14import {
15 DEFAULT_SERVER_SETTINGS,
16 type EnvironmentId,
17 ServerSettings,
18 type ServerSettingsPatch,
19} from "@t3tools/contracts";
1520import {
1621 type ClientSettingsPatch,
1722 type ClientSettings,
1823 DEFAULT_CLIENT_SETTINGS,
19 UnifiedSettings,
24 type UnifiedSettings,
2025} from "@t3tools/contracts/settings";
2126import { ensureLocalApi } from "~/localApi";
2227import * as Struct from "effect/Struct";
......@@ -153,11 +158,6 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
153158
154159// ── Hooks ────────────────────────────────────────────────────────────
155160
156/**
157 * Read merged settings. Selector narrows the subscription so components
158 * only re-render when the slice they care about changes.
159 */
160
161161/**
162162 * Non-hook accessor for the current merged client settings snapshot.
163163 * Used by non-React code paths (e.g. runtime services) that need the latest
......@@ -175,45 +175,77 @@ export function useClientSettingsHydrated(): boolean {
175175 );
176176}
177177
178export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
179 const serverSettings = useAtomValue(primaryServerSettingsAtom);
180 const clientSettings = useSyncExternalStore(
178function useClientSettingsValue(): ClientSettings {
179 return useSyncExternalStore(
181180 subscribeClientSettings,
182181 getClientSettingsSnapshot,
183182 () => DEFAULT_CLIENT_SETTINGS,
184183 );
184}
185
186export function mergeEnvironmentSettings(
187 serverSettings: ServerSettings,
188 clientSettings: ClientSettings,
189): UnifiedSettings {
190 return { ...serverSettings, ...clientSettings };
191}
192
193function useMergedSettings<T>(
194 serverSettings: ServerSettings,
195 selector: ((settings: UnifiedSettings) => T) | undefined,
196): T {
197 const clientSettings = useClientSettingsValue();
185198
186199 const merged = useMemo<UnifiedSettings>(
187 () => ({
188 ...serverSettings,
189 ...clientSettings,
190 }),
200 () => mergeEnvironmentSettings(serverSettings, clientSettings),
191201 [clientSettings, serverSettings],
192202 );
193203
194204 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
195205}
196206
207export function useClientSettings<T = ClientSettings>(
208 selector?: (settings: ClientSettings) => T,
209): T {
210 const settings = useClientSettingsValue();
211 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
212}
213
214/** Read current settings for one environment, merged with client-local preferences. */
215export function useEnvironmentSettings<T = UnifiedSettings>(
216 environmentId: EnvironmentId,
217 selector?: (settings: UnifiedSettings) => T,
218): T {
219 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));
220 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);
221}
222
223/** Primary-only settings access for the settings UI and other explicitly global surfaces. */
224export function usePrimarySettings<T = UnifiedSettings>(
225 selector?: (settings: UnifiedSettings) => T,
226): T {
227 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);
228}
229
197230/**
198231 * Returns an updater that routes each key to the correct backing store.
199232 *
200233 * Server keys are optimistically patched in atom-backed server state, then
201234 * persisted via RPC. Client keys go through client persistence.
202235 */
203export function useUpdateSettings() {
236function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
204237 const persistServerSettings = useAtomCommand(
205238 serverEnvironment.updateSettings,
206239 "server settings update",
207240 );
208 const primaryEnvironment = usePrimaryEnvironment();
209241 const updateSettings = useCallback(
210242 (patch: Partial<UnifiedSettings>) => {
211243 const { serverPatch, clientPatch } = splitPatch(patch);
212244
213245 if (Object.keys(serverPatch).length > 0) {
214 if (primaryEnvironment) {
246 if (environmentId) {
215247 void persistServerSettings({
216 environmentId: primaryEnvironment.environmentId,
248 environmentId,
217249 input: { patch: serverPatch },
218250 });
219251 }
......@@ -226,12 +258,29 @@ export function useUpdateSettings() {
226258 });
227259 }
228260 },
229 [persistServerSettings, primaryEnvironment],
261 [environmentId, persistServerSettings],
230262 );
231263
232264 return updateSettings;
233265}
234266
267export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
268 return useUpdateSettingsTarget(environmentId);
269}
270
271export function useUpdatePrimarySettings() {
272 return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null);
273}
274
275export function useUpdateClientSettings() {
276 return useCallback((patch: ClientSettingsPatch) => {
277 persistClientSettings({
278 ...getClientSettingsSnapshot(),
279 ...patch,
280 });
281 }, []);
282}
283
235284export function __resetClientSettingsPersistenceForTests(): void {
236285 clientSettingsHydrationGeneration += 1;
237286 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
apps/web/src/hooks/useThreadActions.ts+3-3
......@@ -24,7 +24,7 @@ import { useTerminalUiStateStore } from "../terminalUiStateStore";
2424import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
2525import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
2626import { stackedThreadToast, toastManager } from "../components/ui/toast";
27import { useSettings } from "./useSettings";
27import { useClientSettings } from "./useSettings";
2828import { useAtomCommand } from "../state/use-atom-command";
2929
3030export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{
......@@ -49,8 +49,8 @@ export function useThreadActions() {
4949 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
5050 reportFailure: false,
5151 });
52 const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
53 const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);
52 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
53 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
5454 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
5555 const clearProjectDraftThreadById = useComposerDraftStore(
5656 (store) => store.clearProjectDraftThreadById,
apps/web/src/lib/chatThreadActions.test.ts+2-6
......@@ -18,7 +18,6 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
1818 activeDraftThread: null,
1919 activeThread: undefined,
2020 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID),
21 defaultThreadEnvMode: "local",
2221 handleNewThread: async () => {},
2322 ...overrides,
2423 };
......@@ -118,21 +117,18 @@ describe("chatThreadActions", () => {
118117 });
119118 });
120119
121 it("starts a local thread with the configured default env mode", async () => {
120 it("delegates the target environment defaults to the new-thread handler", async () => {
122121 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
123122
124123 const didStart = await startNewLocalThreadFromContext(
125124 createContext({
126125 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
127 defaultThreadEnvMode: "worktree",
128126 handleNewThread,
129127 }),
130128 );
131129
132130 expect(didStart).toBe(true);
133 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {
134 envMode: "worktree",
135 });
131 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
136132 });
137133
138134 it("does not start a thread when there is no project context", async () => {
apps/web/src/lib/chatThreadActions.ts+1-8
......@@ -32,7 +32,6 @@ export interface ChatThreadActionContext {
3232 readonly activeDraftThread: DraftThreadContextLike | null;
3333 readonly activeThread: ThreadContextLike | undefined;
3434 readonly defaultProjectRef: ScopedProjectRef | null;
35 readonly defaultThreadEnvMode: DraftThreadEnvMode;
3635 readonly handleNewThread: NewThreadHandler;
3736}
3837
......@@ -72,12 +71,6 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre
7271 };
7372}
7473
75function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions {
76 return {
77 envMode: context.defaultThreadEnvMode,
78 };
79}
80
8174export async function startNewThreadInProjectFromContext(
8275 context: ChatThreadActionContext,
8376 projectRef: ScopedProjectRef,
......@@ -105,6 +98,6 @@ export async function startNewLocalThreadFromContext(
10598 return false;
10699 }
107100
108 await context.handleNewThread(projectRef, buildDefaultThreadOptions(context));
101 await context.handleNewThread(projectRef);
109102 return true;
110103}
apps/web/src/routes/__root.tsx+2-2
......@@ -26,7 +26,7 @@ import {
2626 toastManager,
2727} from "../components/ui/toast";
2828import { resolveAndPersistPreferredEditor } from "../editorPreferences";
29import { useSettings } from "../hooks/useSettings";
29import { useClientSettings } from "../hooks/useSettings";
3030import {
3131 deriveLogicalProjectKeyFromSettings,
3232 derivePhysicalProjectKeyFromPath,
......@@ -266,7 +266,7 @@ function AuthenticatedTracingBootstrap() {
266266function EventRouter() {
267267 const navigate = useNavigate();
268268 const pathname = useLocation({ select: (loc) => loc.pathname });
269 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
269 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
270270 const primaryEnvironment = usePrimaryEnvironment();
271271 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
272272 reportFailure: false,
apps/web/src/routes/_chat.tsx-11
......@@ -16,9 +16,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
1616import { isPreviewSupportedInRuntime } from "../previewStateStore";
1717import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
1818import { useThreadSelectionStore } from "../threadSelectionStore";
19import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
2019import { stackedThreadToast, toastManager } from "~/components/ui/toast";
21import { useSettings } from "~/hooks/useSettings";
2220import { primaryServerKeybindingsAtom } from "~/state/server";
2321
2422function ChatRouteGlobalShortcuts() {
......@@ -40,8 +38,6 @@ function ChatRouteGlobalShortcuts() {
4038 ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
4139 : false,
4240 );
43 const appSettings = useSettings();
44
4541 useEffect(() => {
4642 const onWindowKeyDown = (event: KeyboardEvent) => {
4743 if (event.defaultPrevented) return;
......@@ -71,9 +67,6 @@ function ChatRouteGlobalShortcuts() {
7167 activeDraftThread,
7268 activeThread: activeThread ?? undefined,
7369 defaultProjectRef,
74 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
75 defaultEnvMode: appSettings.defaultThreadEnvMode,
76 }),
7770 handleNewThread,
7871 });
7972 return;
......@@ -86,9 +79,6 @@ function ChatRouteGlobalShortcuts() {
8679 activeDraftThread,
8780 activeThread: activeThread ?? undefined,
8881 defaultProjectRef,
89 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
90 defaultEnvMode: appSettings.defaultThreadEnvMode,
91 }),
9282 handleNewThread,
9383 });
9484 return;
......@@ -153,7 +143,6 @@ function ChatRouteGlobalShortcuts() {
153143 routeThreadRef,
154144 selectedThreadKeysSize,
155145 terminalOpen,
156 appSettings.defaultThreadEnvMode,
157146 ]);
158147
159148 return null;
apps/web/src/state/entities.ts+6
......@@ -12,12 +12,14 @@ import type {
1212 OrchestrationThreadActivity,
1313 ScopedProjectRef,
1414 ScopedThreadRef,
15 ServerConfig,
1516} from "@t3tools/contracts";
1617import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
1718import { Atom } from "effect/unstable/reactivity";
1819import { useMemo } from "react";
1920import { appAtomRegistry } from "../rpc/atomRegistry";
2021import { environmentProjects } from "./projects";
22import { environmentServerConfigsAtom } from "./server";
2123import { environmentThreadDetails, environmentThreadShells } from "./threads";
2224
2325const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
......@@ -103,6 +105,10 @@ export function useProjects(): ReadonlyArray<EnvironmentProject> {
103105 return useAtomValue(environmentProjects.projectsAtom);
104106}
105107
108export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
109 return useAtomValue(environmentServerConfigsAtom);
110}
111
106112export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
107113 return useAtomValue(environmentThreadShells.threadShellsAtom);
108114}
apps/web/src/state/environments.ts+1-10
......@@ -5,11 +5,11 @@ import {
55} from "@t3tools/client-runtime/connection";
66import type { EnvironmentId } from "@t3tools/contracts";
77import * as Option from "effect/Option";
8import { Atom } from "effect/unstable/reactivity";
98import { useMemo } from "react";
109
1110import { environmentCatalog } from "../connection/catalog";
1211import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
12import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
1313import { useEnvironmentQuery } from "./query";
1414import { relayEnvironmentDiscovery } from "./relay";
1515import { usePreparedConnection } from "./session";
......@@ -21,15 +21,6 @@ export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
2121 readonly relayManaged: boolean;
2222}
2323
24export const primaryEnvironmentIdAtom = Atom.make((get) => {
25 for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) {
26 if (entry.target._tag === "PrimaryConnectionTarget") {
27 return environmentId;
28 }
29 }
30 return null;
31}).pipe(Atom.withLabel("web-primary-environment-id"));
32
3324function projectEnvironmentPresentation(
3425 environmentId: EnvironmentId,
3526 presentation: BaseEnvironmentPresentation,
apps/web/src/state/presentation.ts+2-2
......@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
55import { Atom } from "effect/unstable/reactivity";
66
77import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";
8import { serverEnvironment } from "./server";
99
1010export const environmentPresentations = createEnvironmentPresentationAtoms({
1111 catalogValueAtom: environmentCatalog.catalogValueAtom,
1212 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,
13 serverConfigValueAtom: serverEnvironment.configValueAtom,
1414});
1515
1616const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(
apps/web/src/state/primaryEnvironment.ts created+12
......@@ -0,0 +1,12 @@
1import { Atom } from "effect/unstable/reactivity";
2
3import { environmentCatalog } from "../connection/catalog";
4
5export const primaryEnvironmentIdAtom = Atom.make((get) => {
6 for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) {
7 if (entry.target._tag === "PrimaryConnectionTarget") {
8 return environmentId;
9 }
10 }
11 return null;
12}).pipe(Atom.withLabel("web-primary-environment-id"));
apps/web/src/state/server.ts+3-3
......@@ -15,15 +15,15 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
1515
1616import { environmentCatalog } from "../connection/catalog";
1717import { connectionAtomRuntime } from "../connection/runtime";
18import { primaryEnvironmentIdAtom } from "./environments";
18import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
1919import { environmentSession } from "./session";
2020
2121export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
22 initialConfigValueAtom: environmentSession.configValueAtom,
22 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
2323});
2424export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
2525 catalogValueAtom: environmentCatalog.catalogValueAtom,
26 configValueAtom: serverEnvironment.configValueAtom,
26 serverConfigValueAtom: serverEnvironment.configValueAtom,
2727});
2828
2929interface PrimaryServerState {
packages/client-runtime/src/state/presentation.ts+3-2
......@@ -26,7 +26,8 @@ export function createEnvironmentPresentationAtoms<E>(input: {
2626 readonly stateAtom: (
2727 environmentId: EnvironmentId,
2828 ) => Atom.Atom<AsyncResult.AsyncResult<SupervisorConnectionState, E>>;
29 readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
29 /** Authoritative live server config, including streamed provider/settings updates. */
30 readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
3031}) {
3132 const presentationAtom = Atom.family((environmentId: EnvironmentId) =>
3233 Atom.make((get) => {
......@@ -41,7 +42,7 @@ export function createEnvironmentPresentationAtoms<E>(input: {
4142 return {
4243 entry,
4344 connection: presentEnvironmentConnection(state),
44 serverConfig: get(input.configValueAtom(environmentId)),
45 serverConfig: get(input.serverConfigValueAtom(environmentId)),
4546 } satisfies EnvironmentPresentation;
4647 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),
4748 );
packages/client-runtime/src/state/projectGrouping.ts+2-2
......@@ -1,6 +1,6 @@
11import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
22import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import type { UnifiedSettings } from "@t3tools/contracts/settings";
3import type { ClientSettings } from "@t3tools/contracts/settings";
44
55import type { EnvironmentProject } from "./models.ts";
66import { normalizeProjectPathForComparison } from "./projects.ts";
......@@ -12,7 +12,7 @@ export interface ProjectGroupingSettings {
1212
1313export type ProjectGroupingMode = SidebarProjectGroupingMode;
1414
15export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {
15export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
1616 return {
1717 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
1818 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
packages/client-runtime/src/state/server.ts+12
......@@ -118,9 +118,21 @@ export function createServerEnvironmentAtoms<R, E>(
118118 return projection?.config ?? get(options.initialConfigValueAtom(environmentId));
119119 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
120120 });
121 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>
122 Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe(
123 Atom.withLabel(`environment-data:server:settings:${environmentId}`),
124 ),
125 );
126 const providersValueAtom = Atom.family((environmentId: EnvironmentId) =>
127 Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe(
128 Atom.withLabel(`environment-data:server:providers:${environmentId}`),
129 ),
130 );
121131
122132 return {
123133 configValueAtom,
134 settingsValueAtom,
135 providersValueAtom,
124136 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
125137 label: "environment-data:server:trace-diagnostics",
126138 tag: WS_METHODS.serverGetTraceDiagnostics,
packages/client-runtime/src/state/session.ts+10-5
......@@ -26,7 +26,7 @@ export function initialConfigOption<E>(
2626export function createEnvironmentSessionAtoms<R, E>(
2727 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
2828) {
29 const configAtom = Atom.family((environmentId: EnvironmentId) =>
29 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
3030 runtime.atom(
3131 followStreamInEnvironment(
3232 environmentId,
......@@ -49,10 +49,15 @@ export function createEnvironmentSessionAtoms<R, E>(
4949 ),
5050 );
5151
52 const configValueAtom = Atom.family((environmentId: EnvironmentId) =>
52 // This is only the bootstrap config captured when a transport session is
53 // established. Consumers that need current provider/settings state must use
54 // createServerEnvironmentAtoms(...).configValueAtom instead.
55 const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) =>
5356 Atom.make((get): ServerConfig | null =>
5457 Option.getOrNull(
55 Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()),
58 Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () =>
59 Option.none(),
60 ),
5661 ),
5762 ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)),
5863 );
......@@ -80,8 +85,8 @@ export function createEnvironmentSessionAtoms<R, E>(
8085 );
8186
8287 return {
83 configAtom,
84 configValueAtom,
88 initialConfigAtom,
89 initialConfigValueAtom,
8590 preparedConnectionAtom,
8691 preparedConnectionValueAtom,
8792 };
packages/client-runtime/src/state/shell.test.ts+1-1
......@@ -75,7 +75,7 @@ function makeHarness() {
7575 });
7676 const serverConfigsAtom = createEnvironmentServerConfigsAtom({
7777 catalogValueAtom,
78 configValueAtom: configAtoms,
78 serverConfigValueAtom: configAtoms,
7979 });
8080
8181 return {
packages/client-runtime/src/state/shell.ts+2-2
......@@ -268,13 +268,13 @@ export function createEnvironmentShellSummaryAtom(input: {
268268
269269export function createEnvironmentServerConfigsAtom(input: {
270270 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
271 readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
271 readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
272272}) {
273273 let previousServerConfigs = EMPTY_SERVER_CONFIGS;
274274 return Atom.make((get) => {
275275 const next = new Map<EnvironmentId, ServerConfig>();
276276 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
277 const config = get(input.configValueAtom(environmentId));
277 const config = get(input.serverConfigValueAtom(environmentId));
278278 if (config !== null) {
279279 next.set(environmentId, config);
280280 }