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 {...@@ -12,8 +12,7 @@ import type {
12import { Atom } from "effect/unstable/reactivity";12import { Atom } from "effect/unstable/reactivity";
1313
14import { environmentProjects } from "./projects";14import { environmentProjects } from "./projects";
15import { environmentServerConfigsAtom } from "./server";15import { environmentServerConfigsAtom, serverEnvironment } from "./server";
16import { environmentSession } from "./session";
17import { environmentThreadShells } from "./threads";16import { environmentThreadShells } from "./threads";
1817
19const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(18const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
...@@ -50,7 +49,7 @@ export function useEnvironmentServerConfig(...@@ -50,7 +49,7 @@ export function useEnvironmentServerConfig(
50 return useAtomValue(49 return useAtomValue(
51 environmentId === null50 environmentId === null
52 ? EMPTY_SERVER_CONFIG_ATOM51 ? EMPTY_SERVER_CONFIG_ATOM
53 : environmentSession.configValueAtom(environmentId),52 : serverEnvironment.configValueAtom(environmentId),
54 );53 );
55}54}
5655
apps/mobile/src/state/presentation.ts+2-2
...@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
66
7import { environmentCatalog } from "../connection/catalog";7import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";8import { serverEnvironment } from "./server";
99
10export const environmentPresentations = createEnvironmentPresentationAtoms({10export const environmentPresentations = createEnvironmentPresentationAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 stateAtom: environmentCatalog.stateAtom,12 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,13 serverConfigValueAtom: serverEnvironment.configValueAtom,
14});14});
1515
16const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(16const 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";...@@ -6,9 +6,9 @@ import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSession } from "./session";6import { environmentSession } from "./session";
77
8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {8export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
9 initialConfigValueAtom: environmentSession.configValueAtom,9 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
10});10});
11export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({11export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
12 catalogValueAtom: environmentCatalog.catalogValueAtom,12 catalogValueAtom: environmentCatalog.catalogValueAtom,
13 configValueAtom: serverEnvironment.configValueAtom,13 serverConfigValueAtom: serverEnvironment.configValueAtom,
14});14});
apps/web/src/components/ChatView.tsx+8-4
...@@ -138,7 +138,7 @@ import {...@@ -138,7 +138,7 @@ import {
138} from "~/projectScripts";138} from "~/projectScripts";
139import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";139import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
140import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";140import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
141import { useSettings } from "../hooks/useSettings";141import { useEnvironmentSettings } from "../hooks/useSettings";
142import { resolveAppModelSelectionForInstance } from "../modelSelection";142import { resolveAppModelSelectionForInstance } from "../modelSelection";
143import { getTerminalFocusOwner } from "../lib/terminalFocus";143import { getTerminalFocusOwner } from "../lib/terminalFocus";
144import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";144import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
...@@ -1027,7 +1027,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1027,7 +1027,7 @@ function ChatViewContent(props: ChatViewProps) {
1027 const activeThreadLastVisitedAt = useUiStateStore((store) =>1027 const activeThreadLastVisitedAt = useUiStateStore((store) =>
1028 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,1028 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
1029 );1029 );
1030 const settings = useSettings();1030 const settings = useEnvironmentSettings(environmentId);
1031 const setStickyComposerModelSelection = useComposerDraftStore(1031 const setStickyComposerModelSelection = useComposerDraftStore(
1032 (store) => store.setStickyModelSelection,1032 (store) => store.setStickyModelSelection,
1033 );1033 );
...@@ -1417,7 +1417,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1417,7 +1417,7 @@ function ChatViewContent(props: ChatViewProps) {
1417 },1417 },
1418 [retryEnvironment],1418 [retryEnvironment],
1419 );1419 );
1420 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);1420 const projectGroupingSettings = selectProjectGroupingSettings(settings);
1421 const logicalProjectEnvironments = useMemo(() => {1421 const logicalProjectEnvironments = useMemo(() => {
1422 if (!activeProject) return [];1422 if (!activeProject) return [];
1423 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);1423 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
...@@ -1586,7 +1586,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1586,7 +1586,11 @@ function ChatViewContent(props: ChatViewProps) {
1586 selectedProvider: selectedProviderByThreadId,1586 selectedProvider: selectedProviderByThreadId,
1587 threadProvider,1587 threadProvider,
1588 });1588 });
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);
1590 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);1594 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
1591 const versionMismatchDismissKey =1595 const versionMismatchDismissKey =
1592 versionMismatch && activeThread1596 versionMismatch && activeThread
apps/web/src/components/CommandPalette.tsx+14-38
...@@ -45,7 +45,7 @@ import {...@@ -45,7 +45,7 @@ import {
45import { useAtomValue } from "@effect/atom-react";45import { useAtomValue } from "@effect/atom-react";
46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
47import { useHandleNewThread } from "../hooks/useHandleNewThread";47import { useHandleNewThread } from "../hooks/useHandleNewThread";
48import { useSettings } from "../hooks/useSettings";48import { useClientSettings } from "../hooks/useSettings";
49import { readLocalApi } from "../localApi";49import { readLocalApi } from "../localApi";
50import { filesystemEnvironment } from "../state/filesystem";50import { filesystemEnvironment } from "../state/filesystem";
51import { projectEnvironment } from "../state/projects";51import { projectEnvironment } from "../state/projects";
...@@ -447,7 +447,7 @@ function OpenCommandPaletteDialog(props: {...@@ -447,7 +447,7 @@ function OpenCommandPaletteDialog(props: {
447 const deferredQuery = useDeferredValue(query);447 const deferredQuery = useDeferredValue(query);
448 const isActionsOnly = deferredQuery.startsWith(">");448 const isActionsOnly = deferredQuery.startsWith(">");
449 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);449 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
450 const settings = useSettings();450 const clientSettings = useClientSettings();
451 const createProject = useAtomCommand(projectEnvironment.create, {451 const createProject = useAtomCommand(projectEnvironment.create, {
452 reportFailure: false,452 reportFailure: false,
453 });453 });
...@@ -524,16 +524,14 @@ function OpenCommandPaletteDialog(props: {...@@ -524,16 +524,14 @@ function OpenCommandPaletteDialog(props: {
524 const environment = environments.find(524 const environment = environments.find(
525 (candidate) => candidate.environmentId === environmentId,525 (candidate) => candidate.environmentId === environmentId,
526 );526 );
527 const environmentSettings =527 const environmentSettings = environment?.serverConfig?.settings ?? null;
528 environment?.serverConfig?.settings ??
529 (environmentId === primaryEnvironmentId ? settings : null);
530 const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? "";528 const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? "";
531 if (baseDirectory.length === 0) {529 if (baseDirectory.length === 0) {
532 return "~/";530 return "~/";
533 }531 }
534 return ensureBrowseDirectoryPath(baseDirectory);532 return ensureBrowseDirectoryPath(baseDirectory);
535 },533 },
536 [environments, primaryEnvironmentId, settings],534 [environments],
537 );535 );
538536
539 const projectCwdById = useMemo(537 const projectCwdById = useMemo(
...@@ -589,7 +587,7 @@ function OpenCommandPaletteDialog(props: {...@@ -589,7 +587,7 @@ function OpenCommandPaletteDialog(props: {
589 const latestThread = getLatestThreadForProject(587 const latestThread = getLatestThreadForProject(
590 threads.filter((thread) => thread.environmentId === project.environmentId),588 threads.filter((thread) => thread.environmentId === project.environmentId),
591 project.id,589 project.id,
592 settings.sidebarThreadSortOrder,590 clientSettings.sidebarThreadSortOrder,
593 );591 );
594 if (latestThread) {592 if (latestThread) {
595 await navigate({593 await navigate({
...@@ -601,17 +599,9 @@ function OpenCommandPaletteDialog(props: {...@@ -601,17 +599,9 @@ function OpenCommandPaletteDialog(props: {
601 return;599 return;
602 }600 }
603601
604 await handleNewThread(scopeProjectRef(project.environmentId, project.id), {602 await handleNewThread(scopeProjectRef(project.environmentId, project.id));
605 envMode: settings.defaultThreadEnvMode,
606 });
607 },603 },
608 [604 [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads],
609 handleNewThread,
610 navigate,
611 settings.defaultThreadEnvMode,
612 settings.sidebarThreadSortOrder,
613 threads,
614 ],
615 );605 );
616606
617 const projectSearchItems = useMemo(607 const projectSearchItems = useMemo(
...@@ -650,21 +640,13 @@ function OpenCommandPaletteDialog(props: {...@@ -650,21 +640,13 @@ function OpenCommandPaletteDialog(props: {
650 activeDraftThread,640 activeDraftThread,
651 activeThread: activeThread ?? undefined,641 activeThread: activeThread ?? undefined,
652 defaultProjectRef,642 defaultProjectRef,
653 defaultThreadEnvMode: settings.defaultThreadEnvMode,
654 handleNewThread,643 handleNewThread,
655 },644 },
656 scopeProjectRef(project.environmentId, project.id),645 scopeProjectRef(project.environmentId, project.id),
657 );646 );
658 },647 },
659 }),648 }),
660 [649 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
661 activeDraftThread,
662 activeThread,
663 defaultProjectRef,
664 handleNewThread,
665 projects,
666 settings.defaultThreadEnvMode,
667 ],
668 );650 );
669651
670 const allThreadItems = useMemo(652 const allThreadItems = useMemo(
...@@ -673,7 +655,7 @@ function OpenCommandPaletteDialog(props: {...@@ -673,7 +655,7 @@ function OpenCommandPaletteDialog(props: {
673 threads,655 threads,
674 ...(activeThreadId ? { activeThreadId } : {}),656 ...(activeThreadId ? { activeThreadId } : {}),
675 projectTitleById,657 projectTitleById,
676 sortOrder: settings.sidebarThreadSortOrder,658 sortOrder: clientSettings.sidebarThreadSortOrder,
677 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,659 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
678 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,660 renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
679 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,661 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
...@@ -684,7 +666,7 @@ function OpenCommandPaletteDialog(props: {...@@ -684,7 +666,7 @@ function OpenCommandPaletteDialog(props: {
684 });666 });
685 },667 },
686 }),668 }),
687 [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads],669 [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads],
688 );670 );
689 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);671 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
690672
...@@ -956,7 +938,6 @@ function OpenCommandPaletteDialog(props: {...@@ -956,7 +938,6 @@ function OpenCommandPaletteDialog(props: {
956 activeDraftThread,938 activeDraftThread,
957 activeThread: activeThread ?? undefined,939 activeThread: activeThread ?? undefined,
958 defaultProjectRef,940 defaultProjectRef,
959 defaultThreadEnvMode: settings.defaultThreadEnvMode,
960 handleNewThread,941 handleNewThread,
961 });942 });
962 },943 },
...@@ -1072,7 +1053,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1072,7 +1053,7 @@ function OpenCommandPaletteDialog(props: {
1072 const latestThread = getLatestThreadForProject(1053 const latestThread = getLatestThreadForProject(
1073 threads.filter((thread) => thread.environmentId === existing.environmentId),1054 threads.filter((thread) => thread.environmentId === existing.environmentId),
1074 existing.id,1055 existing.id,
1075 settings.sidebarThreadSortOrder,1056 clientSettings.sidebarThreadSortOrder,
1076 );1057 );
1077 if (latestThread) {1058 if (latestThread) {
1078 await navigate({1059 await navigate({
...@@ -1083,9 +1064,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1083,9 +1064,7 @@ function OpenCommandPaletteDialog(props: {
1083 });1064 });
1084 } else {1065 } else {
1085 const navigationResult = await settlePromise(() =>1066 const navigationResult = await settlePromise(() =>
1086 handleNewThread(scopeProjectRef(existing.environmentId, existing.id), {1067 handleNewThread(scopeProjectRef(existing.environmentId, existing.id)),
1087 envMode: settings.defaultThreadEnvMode,
1088 }),
1089 );1068 );
1090 if (navigationResult._tag === "Failure") {1069 if (navigationResult._tag === "Failure") {
1091 const error = squashAtomCommandFailure(navigationResult);1070 const error = squashAtomCommandFailure(navigationResult);
...@@ -1132,9 +1111,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1132,9 +1111,7 @@ function OpenCommandPaletteDialog(props: {
1132 }1111 }
11331112
1134 const navigationResult = await settlePromise(() =>1113 const navigationResult = await settlePromise(() =>
1135 handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), {1114 handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)),
1136 envMode: settings.defaultThreadEnvMode,
1137 }),
1138 );1115 );
1139 if (navigationResult._tag === "Failure") {1116 if (navigationResult._tag === "Failure") {
1140 const error = squashAtomCommandFailure(navigationResult);1117 const error = squashAtomCommandFailure(navigationResult);
...@@ -1158,8 +1135,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1158,8 +1135,7 @@ function OpenCommandPaletteDialog(props: {
1158 navigate,1135 navigate,
1159 projects,1136 projects,
1160 setOpen,1137 setOpen,
1161 settings.defaultThreadEnvMode,1138 clientSettings.sidebarThreadSortOrder,
1162 settings.sidebarThreadSortOrder,
1163 threads,1139 threads,
1164 ],1140 ],
1165 );1141 );
apps/web/src/components/DiffPanel.tsx+2-2
...@@ -34,7 +34,7 @@ import {...@@ -34,7 +34,7 @@ import {
34import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";34import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
35import { useProject, useThread } from "../state/entities";35import { useProject, useThread } from "../state/entities";
36import { resolveThreadRouteRef } from "../threadRoutes";36import { resolveThreadRouteRef } from "../threadRoutes";
37import { useSettings } from "../hooks/useSettings";37import { useClientSettings } from "../hooks/useSettings";
38import { formatShortTimestamp } from "../timestampFormat";38import { formatShortTimestamp } from "../timestampFormat";
39import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";39import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
40import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff";40import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff";
...@@ -183,7 +183,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";...@@ -183,7 +183,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
183183
184export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) {184export default function DiffPanel({ mode = "inline", composerDraftTarget }: DiffPanelProps) {
185 const { resolvedTheme } = useTheme();185 const { resolvedTheme } = useTheme();
186 const settings = useSettings();186 const settings = useClientSettings();
187 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");187 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
188 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);188 const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
189 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);189 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
apps/web/src/components/Sidebar.tsx+21-20
...@@ -41,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-...@@ -41,11 +41,11 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
41import { CSS } from "@dnd-kit/utilities";41import { CSS } from "@dnd-kit/utilities";
42import {42import {
43 type ContextMenuItem,43 type ContextMenuItem,
44 DEFAULT_SERVER_SETTINGS,
44 ProjectId,45 ProjectId,
45 type ScopedThreadRef,46 type ScopedThreadRef,
46 type ResolvedKeybindingsConfig,47 type ResolvedKeybindingsConfig,
47 type SidebarProjectGroupingMode,48 type SidebarProjectGroupingMode,
48 type ThreadEnvMode,
49 ThreadId,49 ThreadId,
50} from "@t3tools/contracts";50} from "@t3tools/contracts";
51import {51import {
...@@ -77,6 +77,7 @@ import {...@@ -77,6 +77,7 @@ import {
77 readThreadShell,77 readThreadShell,
78 useProject,78 useProject,
79 useProjects,79 useProjects,
80 useServerConfigs,
80 useThreadShells,81 useThreadShells,
81 useThreadShellsForProjectRefs,82 useThreadShellsForProjectRefs,
82} from "../state/entities";83} from "../state/entities";
...@@ -199,7 +200,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";...@@ -199,7 +200,7 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
199import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";200import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
200import { useIsMobile } from "~/hooks/useMediaQuery";201import { useIsMobile } from "~/hooks/useMediaQuery";
201import { CommandDialogTrigger } from "./ui/command";202import { CommandDialogTrigger } from "./ui/command";
202import { useSettings, useUpdateSettings } from "~/hooks/useSettings";203import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
203import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";204import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";
204import {205import {
205 derivePhysicalProjectKey,206 derivePhysicalProjectKey,
...@@ -1084,19 +1085,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1084,19 +1085,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1084 isManualProjectSorting,1085 isManualProjectSorting,
1085 dragHandleProps,1086 dragHandleProps,
1086 } = props;1087 } = props;
1087 const threadSortOrder = useSettings<SidebarThreadSortOrder>(1088 const threadSortOrder = useClientSettings<SidebarThreadSortOrder>(
1088 (settings) => settings.sidebarThreadSortOrder,1089 (settings) => settings.sidebarThreadSortOrder,
1089 );1090 );
1090 const appSettingsConfirmThreadDelete = useSettings<boolean>(1091 const appSettingsConfirmThreadDelete = useClientSettings<boolean>(
1091 (settings) => settings.confirmThreadDelete,1092 (settings) => settings.confirmThreadDelete,
1092 );1093 );
1093 const appSettingsConfirmThreadArchive = useSettings<boolean>(1094 const appSettingsConfirmThreadArchive = useClientSettings<boolean>(
1094 (settings) => settings.confirmThreadArchive,1095 (settings) => settings.confirmThreadArchive,
1095 );1096 );
1096 const defaultThreadEnvMode = useSettings<ThreadEnvMode>(1097 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1097 (settings) => settings.defaultThreadEnvMode,1098 const serverConfigs = useServerConfigs();
1098 );
1099 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
1100 const deleteProject = useAtomCommand(projectEnvironment.delete, {1099 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1101 reportFailure: false,1100 reportFailure: false,
1102 });1101 });
...@@ -1106,8 +1105,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1106,8 +1105,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1106 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1105 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1107 reportFailure: false,1106 reportFailure: false,
1108 });1107 });
1109 const updateSettings = useUpdateSettings();1108 const updateSettings = useUpdateClientSettings();
1110 const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>(1109 const sidebarThreadPreviewCount = useClientSettings<SidebarThreadPreviewCount>(
1111 (settings) => settings.sidebarThreadPreviewCount,1110 (settings) => settings.sidebarThreadPreviewCount,
1112 );1111 );
1113 const router = useRouter();1112 const router = useRouter();
...@@ -1840,7 +1839,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1840,7 +1839,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1840 const seedContext = resolveSidebarNewThreadSeedContext({1839 const seedContext = resolveSidebarNewThreadSeedContext({
1841 projectId: member.id,1840 projectId: member.id,
1842 defaultEnvMode: resolveSidebarNewThreadEnvMode({1841 defaultEnvMode: resolveSidebarNewThreadEnvMode({
1843 defaultEnvMode: defaultThreadEnvMode,1842 defaultEnvMode:
1843 serverConfigs.get(member.environmentId)?.settings.defaultThreadEnvMode ??
1844 DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
1844 }),1845 }),
1845 activeThread:1846 activeThread:
1846 currentActiveThread && currentActiveThread.projectId === member.id1847 currentActiveThread && currentActiveThread.projectId === member.id
...@@ -1889,7 +1890,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1889,7 +1890,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1889 }1890 }
1890 })();1891 })();
1891 },1892 },
1892 [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile],1893 [handleNewThread, isMobile, router, serverConfigs, setOpenMobile],
1893 );1894 );
18941895
1895 const handleCreateThreadClick = useCallback(1896 const handleCreateThreadClick = useCallback(
...@@ -2745,7 +2746,7 @@ interface SidebarProjectsContentProps {...@@ -2745,7 +2746,7 @@ interface SidebarProjectsContentProps {
2745 threadSortOrder: SidebarThreadSortOrder;2746 threadSortOrder: SidebarThreadSortOrder;
2746 projectGroupingMode: SidebarProjectGroupingMode;2747 projectGroupingMode: SidebarProjectGroupingMode;
2747 threadPreviewCount: SidebarThreadPreviewCount;2748 threadPreviewCount: SidebarThreadPreviewCount;
2748 updateSettings: ReturnType<typeof useUpdateSettings>;2749 updateSettings: ReturnType<typeof useUpdateClientSettings>;
2749 openAddProject: () => void;2750 openAddProject: () => void;
2750 isManualProjectSorting: boolean;2751 isManualProjectSorting: boolean;
2751 projectDnDSensors: ReturnType<typeof useSensors>;2752 projectDnDSensors: ReturnType<typeof useSensors>;
...@@ -3014,12 +3015,12 @@ export default function Sidebar() {...@@ -3014,12 +3015,12 @@ export default function Sidebar() {
3014 const navigate = useNavigate();3015 const navigate = useNavigate();
3015 const pathname = useLocation({ select: (loc) => loc.pathname });3016 const pathname = useLocation({ select: (loc) => loc.pathname });
3016 const isOnSettings = pathname.startsWith("/settings");3017 const isOnSettings = pathname.startsWith("/settings");
3017 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);3018 const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
3018 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);3019 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
3019 const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode);3020 const sidebarProjectGroupingMode = useClientSettings((s) => s.sidebarProjectGroupingMode);
3020 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);3021 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
3021 const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount);3022 const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount);
3022 const updateSettings = useUpdateSettings();3023 const updateSettings = useUpdateClientSettings();
3023 const handleNewThread = useNewThreadHandler();3024 const handleNewThread = useNewThreadHandler();
3024 const { archiveThread, deleteThread } = useThreadActions();3025 const { archiveThread, deleteThread } = useThreadActions();
3025 const { isMobile, setOpenMobile } = useSidebar();3026 const { isMobile, setOpenMobile } = useSidebar();
apps/web/src/components/chat/ModelPickerContent.tsx+3-3
...@@ -19,7 +19,7 @@ import {...@@ -19,7 +19,7 @@ import {
19 resolveShortcutCommand,19 resolveShortcutCommand,
20 shortcutLabelForCommand,20 shortcutLabelForCommand,
21} from "../../keybindings";21} from "../../keybindings";
22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";22import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
23import { cn } from "~/lib/utils";23import { cn } from "~/lib/utils";
24import { TooltipProvider } from "../ui/tooltip";24import { TooltipProvider } from "../ui/tooltip";
25import {25import {
...@@ -102,7 +102,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -102,7 +102,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
102 const searchInputRef = useRef<HTMLInputElement>(null);102 const searchInputRef = useRef<HTMLInputElement>(null);
103 const modelListRef = useRef<LegendListRef | null>(null);103 const modelListRef = useRef<LegendListRef | null>(null);
104 const highlightedModelKeyRef = useRef<string | null>(null);104 const highlightedModelKeyRef = useRef<string | null>(null);
105 const favorites = useSettings((s) => s.favorites ?? []);105 const favorites = useClientSettings((s) => s.favorites ?? []);
106 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(106 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
107 () => {107 () => {
108 if (props.lockedProvider !== null) {108 if (props.lockedProvider !== null) {
...@@ -117,7 +117,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -117,7 +117,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
117 () => providedKeybindings ?? [],117 () => providedKeybindings ?? [],
118 [providedKeybindings],118 [providedKeybindings],
119 );119 );
120 const updateSettings = useUpdateSettings();120 const updateSettings = useUpdateClientSettings();
121121
122 const focusSearchInput = useCallback(() => {122 const focusSearchInput = useCallback(() => {
123 searchInputRef.current?.focus({ preventScroll: true });123 searchInputRef.current?.focus({ preventScroll: true });
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+3-3
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 type ProviderInstanceConfig,9 type ProviderInstanceConfig,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
1111
12import { useSettings, useUpdateSettings } from "../../hooks/useSettings";12import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
13import { cn } from "../../lib/utils";13import { cn } from "../../lib/utils";
14import { normalizeProviderAccentColor } from "../../providerInstances";14import { normalizeProviderAccentColor } from "../../providerInstances";
15import { Button } from "../ui/button";15import { Button } from "../ui/button";
...@@ -114,8 +114,8 @@ interface AddProviderInstanceDialogProps {...@@ -114,8 +114,8 @@ interface AddProviderInstanceDialogProps {
114}114}
115115
116export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {116export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
117 const settings = useSettings();117 const settings = usePrimarySettings();
118 const updateSettings = useUpdateSettings();118 const updateSettings = useUpdatePrimarySettings();
119119
120 const [wizardStep, setWizardStep] = useState(0);120 const [wizardStep, setWizardStep] = useState(0);
121 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);121 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";...@@ -36,7 +36,7 @@ import { TraitsPicker } from "../chat/TraitsPicker";
36import { isElectron } from "../../env";36import { isElectron } from "../../env";
37import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";37import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
38import { useTheme } from "../../hooks/useTheme";38import { useTheme } from "../../hooks/useTheme";
39import { useSettings, useUpdateSettings } from "../../hooks/useSettings";39import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
40import { useThreadActions } from "../../hooks/useThreadActions";40import { useThreadActions } from "../../hooks/useThreadActions";
41import { useDesktopUpdateState } from "../../state/desktopUpdate";41import { useDesktopUpdateState } from "../../state/desktopUpdate";
42import {42import {
...@@ -373,8 +373,8 @@ function AboutVersionSection() {...@@ -373,8 +373,8 @@ function AboutVersionSection() {
373373
374export function useSettingsRestore(onRestored?: () => void) {374export function useSettingsRestore(onRestored?: () => void) {
375 const { theme, setTheme } = useTheme();375 const { theme, setTheme } = useTheme();
376 const settings = useSettings();376 const settings = usePrimarySettings();
377 const updateSettings = useUpdateSettings();377 const updateSettings = useUpdatePrimarySettings();
378378
379 const isGitWritingModelDirty = !Equal.equals(379 const isGitWritingModelDirty = !Equal.equals(
380 settings.textGenerationModelSelection ?? null,380 settings.textGenerationModelSelection ?? null,
...@@ -479,8 +479,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -479,8 +479,8 @@ export function useSettingsRestore(onRestored?: () => void) {
479479
480export function GeneralSettingsPanel() {480export function GeneralSettingsPanel() {
481 const { theme, setTheme } = useTheme();481 const { theme, setTheme } = useTheme();
482 const settings = useSettings();482 const settings = usePrimarySettings();
483 const updateSettings = useUpdateSettings();483 const updateSettings = useUpdatePrimarySettings();
484 const observability = useAtomValue(primaryServerObservabilityAtom);484 const observability = useAtomValue(primaryServerObservabilityAtom);
485 const serverProviders = useAtomValue(primaryServerProvidersAtom);485 const serverProviders = useAtomValue(primaryServerProvidersAtom);
486 const diagnosticsDescription = formatDiagnosticsDescription({486 const diagnosticsDescription = formatDiagnosticsDescription({
...@@ -977,8 +977,8 @@ export function GeneralSettingsPanel() {...@@ -977,8 +977,8 @@ export function GeneralSettingsPanel() {
977}977}
978978
979export function ProviderSettingsPanel() {979export function ProviderSettingsPanel() {
980 const settings = useSettings();980 const settings = usePrimarySettings();
981 const updateSettings = useUpdateSettings();981 const updateSettings = useUpdatePrimarySettings();
982 const serverProviders = useAtomValue(primaryServerProvidersAtom);982 const serverProviders = useAtomValue(primaryServerProvidersAtom);
983 const primaryEnvironment = usePrimaryEnvironment();983 const primaryEnvironment = usePrimaryEnvironment();
984 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {984 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
apps/web/src/components/settings/SourceControlSettings.tsx+5-3
...@@ -12,7 +12,7 @@ import type {...@@ -12,7 +12,7 @@ import type {
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1414
15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";15import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
16import { cn } from "../../lib/utils";16import { cn } from "../../lib/utils";
17import { usePrimaryEnvironment } from "../../state/environments";17import { usePrimaryEnvironment } from "../../state/environments";
18import { useEnvironmentQuery } from "../../state/query";18import { useEnvironmentQuery } from "../../state/query";
...@@ -291,8 +291,10 @@ function DiscoveryItemRow({...@@ -291,8 +291,10 @@ function DiscoveryItemRow({
291}291}
292292
293function GitFetchIntervalSettings() {293function GitFetchIntervalSettings() {
294 const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval);294 const automaticGitFetchInterval = usePrimarySettings(
295 const updateSettings = useUpdateSettings();295 (settings) => settings.automaticGitFetchInterval,
296 );
297 const updateSettings = useUpdatePrimarySettings();
296 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);298 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);
297 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(299 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(
298 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,300 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
apps/web/src/hooks/useHandleNewThread.ts+14-10
...@@ -3,7 +3,11 @@ import {...@@ -3,7 +3,11 @@ import {
3 scopeProjectRef,3 scopeProjectRef,
4 scopeThreadRef,4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";5} 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";
7import { useParams, useRouter } from "@tanstack/react-router";11import { useParams, useRouter } from "@tanstack/react-router";
8import { useCallback, useMemo } from "react";12import { useCallback, useMemo } from "react";
9import {13import {
...@@ -19,18 +23,16 @@ import {...@@ -19,18 +23,16 @@ import {
19 getProjectOrderKey,23 getProjectOrderKey,
20 selectProjectGroupingSettings,24 selectProjectGroupingSettings,
21} from "../logicalProject";25} from "../logicalProject";
22import { readThreadShell, useProjects, useThread } from "../state/entities";26import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities";
23import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";27import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
24import { resolveThreadRouteTarget } from "../threadRoutes";28import { resolveThreadRouteTarget } from "../threadRoutes";
25import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";29import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
26import { useSettings } from "./useSettings";30import { useClientSettings } from "./useSettings";
2731
28export function useNewThreadHandler() {32export function useNewThreadHandler() {
29 const projects = useProjects();33 const projects = useProjects();
30 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);34 const serverConfigs = useServerConfigs();
31 const newWorktreesStartFromOrigin = useSettings(35 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
32 (settings) => settings.newWorktreesStartFromOrigin,
33 );
34 const router = useRouter();36 const router = useRouter();
35 const getCurrentRouteTarget = useCallback(() => {37 const getCurrentRouteTarget = useCallback(() => {
36 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};38 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
...@@ -61,6 +63,8 @@ export function useNewThreadHandler() {...@@ -61,6 +63,8 @@ export function useNewThreadHandler() {
61 candidate.id === projectRef.projectId &&63 candidate.id === projectRef.projectId &&
62 candidate.environmentId === projectRef.environmentId,64 candidate.environmentId === projectRef.environmentId,
63 );65 );
66 const environmentSettings =
67 serverConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
64 const logicalProjectKey = project68 const logicalProjectKey = project
65 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)69 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
66 : scopedProjectKey(projectRef);70 : scopedProjectKey(projectRef);
...@@ -155,7 +159,7 @@ export function useNewThreadHandler() {...@@ -155,7 +159,7 @@ export function useNewThreadHandler() {
155 const draftId = newDraftId();159 const draftId = newDraftId();
156 const threadId = newThreadId();160 const threadId = newThreadId();
157 const createdAt = new Date().toISOString();161 const createdAt = new Date().toISOString();
158 const initialEnvMode = options?.envMode ?? "local";162 const initialEnvMode = options?.envMode ?? environmentSettings.defaultThreadEnvMode;
159 return (async () => {163 return (async () => {
160 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {164 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
161 threadId,165 threadId,
...@@ -167,7 +171,7 @@ export function useNewThreadHandler() {...@@ -167,7 +171,7 @@ export function useNewThreadHandler() {
167 options?.startFromOrigin ??171 options?.startFromOrigin ??
168 resolveNewDraftStartFromOrigin({172 resolveNewDraftStartFromOrigin({
169 envMode: initialEnvMode,173 envMode: initialEnvMode,
170 newWorktreesStartFromOrigin,174 newWorktreesStartFromOrigin: environmentSettings.newWorktreesStartFromOrigin,
171 }),175 }),
172 runtimeMode: DEFAULT_RUNTIME_MODE,176 runtimeMode: DEFAULT_RUNTIME_MODE,
173 });177 });
...@@ -179,7 +183,7 @@ export function useNewThreadHandler() {...@@ -179,7 +183,7 @@ export function useNewThreadHandler() {
179 });183 });
180 })();184 })();
181 },185 },
182 [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects],186 [getCurrentRouteTarget, projectGroupingSettings, projects, router, serverConfigs],
183 );187 );
184}188}
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 @@...@@ -1,22 +1,27 @@
1/**1/**
2 * Unified settings hook.2 * Environment-scoped settings hooks.
3 *3 *
4 * Abstracts the split between server-authoritative settings (persisted in4 * Abstracts the split between server-authoritative settings (persisted in
5 * `settings.json` on the server, fetched via `server.getConfig`) and5 * `settings.json` on the server, fetched via `server.getConfig`) and
6 * client-only settings (persisted in localStorage).6 * client-only settings (persisted in localStorage).
7 *7 *
8 * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to8 * Live server settings always require an environment id. Primary-environment
9 * write. The hook transparently routes reads/writes to the correct backing9 * access is intentionally named as such so environment-sensitive consumers
10 * store.10 * cannot silently read the wrong server's settings.
11 */11 */
12import { useCallback, useMemo, useSyncExternalStore } from "react";12import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { useAtomValue } from "@effect/atom-react";13import { 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";
15import {20import {
16 type ClientSettingsPatch,21 type ClientSettingsPatch,
17 type ClientSettings,22 type ClientSettings,
18 DEFAULT_CLIENT_SETTINGS,23 DEFAULT_CLIENT_SETTINGS,
19 UnifiedSettings,24 type UnifiedSettings,
20} from "@t3tools/contracts/settings";25} from "@t3tools/contracts/settings";
21import { ensureLocalApi } from "~/localApi";26import { ensureLocalApi } from "~/localApi";
22import * as Struct from "effect/Struct";27import * as Struct from "effect/Struct";
...@@ -153,11 +158,6 @@ function splitPatch(patch: Partial<UnifiedSettings>): {...@@ -153,11 +158,6 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
153158
154// ── Hooks ────────────────────────────────────────────────────────────159// ── 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
161/**161/**
162 * Non-hook accessor for the current merged client settings snapshot.162 * Non-hook accessor for the current merged client settings snapshot.
163 * Used by non-React code paths (e.g. runtime services) that need the latest163 * Used by non-React code paths (e.g. runtime services) that need the latest
...@@ -175,45 +175,77 @@ export function useClientSettingsHydrated(): boolean {...@@ -175,45 +175,77 @@ export function useClientSettingsHydrated(): boolean {
175 );175 );
176}176}
177177
178export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {178function useClientSettingsValue(): ClientSettings {
179 const serverSettings = useAtomValue(primaryServerSettingsAtom);179 return useSyncExternalStore(
180 const clientSettings = useSyncExternalStore(
181 subscribeClientSettings,180 subscribeClientSettings,
182 getClientSettingsSnapshot,181 getClientSettingsSnapshot,
183 () => DEFAULT_CLIENT_SETTINGS,182 () => DEFAULT_CLIENT_SETTINGS,
184 );183 );
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
186 const merged = useMemo<UnifiedSettings>(199 const merged = useMemo<UnifiedSettings>(
187 () => ({200 () => mergeEnvironmentSettings(serverSettings, clientSettings),
188 ...serverSettings,
189 ...clientSettings,
190 }),
191 [clientSettings, serverSettings],201 [clientSettings, serverSettings],
192 );202 );
193203
194 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);204 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
195}205}
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
197/**230/**
198 * Returns an updater that routes each key to the correct backing store.231 * Returns an updater that routes each key to the correct backing store.
199 *232 *
200 * Server keys are optimistically patched in atom-backed server state, then233 * Server keys are optimistically patched in atom-backed server state, then
201 * persisted via RPC. Client keys go through client persistence.234 * persisted via RPC. Client keys go through client persistence.
202 */235 */
203export function useUpdateSettings() {236function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
204 const persistServerSettings = useAtomCommand(237 const persistServerSettings = useAtomCommand(
205 serverEnvironment.updateSettings,238 serverEnvironment.updateSettings,
206 "server settings update",239 "server settings update",
207 );240 );
208 const primaryEnvironment = usePrimaryEnvironment();
209 const updateSettings = useCallback(241 const updateSettings = useCallback(
210 (patch: Partial<UnifiedSettings>) => {242 (patch: Partial<UnifiedSettings>) => {
211 const { serverPatch, clientPatch } = splitPatch(patch);243 const { serverPatch, clientPatch } = splitPatch(patch);
212244
213 if (Object.keys(serverPatch).length > 0) {245 if (Object.keys(serverPatch).length > 0) {
214 if (primaryEnvironment) {246 if (environmentId) {
215 void persistServerSettings({247 void persistServerSettings({
216 environmentId: primaryEnvironment.environmentId,248 environmentId,
217 input: { patch: serverPatch },249 input: { patch: serverPatch },
218 });250 });
219 }251 }
...@@ -226,12 +258,29 @@ export function useUpdateSettings() {...@@ -226,12 +258,29 @@ export function useUpdateSettings() {
226 });258 });
227 }259 }
228 },260 },
229 [persistServerSettings, primaryEnvironment],261 [environmentId, persistServerSettings],
230 );262 );
231263
232 return updateSettings;264 return updateSettings;
233}265}
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
235export function __resetClientSettingsPersistenceForTests(): void {284export function __resetClientSettingsPersistenceForTests(): void {
236 clientSettingsHydrationGeneration += 1;285 clientSettingsHydrationGeneration += 1;
237 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;286 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
apps/web/src/hooks/useThreadActions.ts+3-3
...@@ -24,7 +24,7 @@ import { useTerminalUiStateStore } from "../terminalUiStateStore";...@@ -24,7 +24,7 @@ import { useTerminalUiStateStore } from "../terminalUiStateStore";
24import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";24import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
25import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";25import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
26import { stackedThreadToast, toastManager } from "../components/ui/toast";26import { stackedThreadToast, toastManager } from "../components/ui/toast";
27import { useSettings } from "./useSettings";27import { useClientSettings } from "./useSettings";
28import { useAtomCommand } from "../state/use-atom-command";28import { useAtomCommand } from "../state/use-atom-command";
2929
30export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{30export class ThreadArchiveBlockedError extends Data.TaggedError("ThreadArchiveBlockedError")<{
...@@ -49,8 +49,8 @@ export function useThreadActions() {...@@ -49,8 +49,8 @@ export function useThreadActions() {
49 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {49 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
50 reportFailure: false,50 reportFailure: false,
51 });51 });
52 const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);52 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
53 const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete);53 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
54 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);54 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
55 const clearProjectDraftThreadById = useComposerDraftStore(55 const clearProjectDraftThreadById = useComposerDraftStore(
56 (store) => store.clearProjectDraftThreadById,56 (store) => store.clearProjectDraftThreadById,
apps/web/src/lib/chatThreadActions.test.ts+2-6
...@@ -18,7 +18,6 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh...@@ -18,7 +18,6 @@ function createContext(overrides: Partial<ChatThreadActionContext> = {}): ChatTh
18 activeDraftThread: null,18 activeDraftThread: null,
19 activeThread: undefined,19 activeThread: undefined,
20 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID),20 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID),
21 defaultThreadEnvMode: "local",
22 handleNewThread: async () => {},21 handleNewThread: async () => {},
23 ...overrides,22 ...overrides,
24 };23 };
...@@ -118,21 +117,18 @@ describe("chatThreadActions", () => {...@@ -118,21 +117,18 @@ describe("chatThreadActions", () => {
118 });117 });
119 });118 });
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 () => {
122 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});121 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
123122
124 const didStart = await startNewLocalThreadFromContext(123 const didStart = await startNewLocalThreadFromContext(
125 createContext({124 createContext({
126 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),125 defaultProjectRef: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID),
127 defaultThreadEnvMode: "worktree",
128 handleNewThread,126 handleNewThread,
129 }),127 }),
130 );128 );
131129
132 expect(didStart).toBe(true);130 expect(didStart).toBe(true);
133 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), {131 expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID));
134 envMode: "worktree",
135 });
136 });132 });
137133
138 it("does not start a thread when there is no project context", async () => {134 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 {...@@ -32,7 +32,6 @@ export interface ChatThreadActionContext {
32 readonly activeDraftThread: DraftThreadContextLike | null;32 readonly activeDraftThread: DraftThreadContextLike | null;
33 readonly activeThread: ThreadContextLike | undefined;33 readonly activeThread: ThreadContextLike | undefined;
34 readonly defaultProjectRef: ScopedProjectRef | null;34 readonly defaultProjectRef: ScopedProjectRef | null;
35 readonly defaultThreadEnvMode: DraftThreadEnvMode;
36 readonly handleNewThread: NewThreadHandler;35 readonly handleNewThread: NewThreadHandler;
37}36}
3837
...@@ -72,12 +71,6 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre...@@ -72,12 +71,6 @@ function buildContextualThreadOptions(context: ChatThreadActionContext): NewThre
72 };71 };
73}72}
7473
75function buildDefaultThreadOptions(context: ChatThreadActionContext): NewThreadOptions {
76 return {
77 envMode: context.defaultThreadEnvMode,
78 };
79}
80
81export async function startNewThreadInProjectFromContext(74export async function startNewThreadInProjectFromContext(
82 context: ChatThreadActionContext,75 context: ChatThreadActionContext,
83 projectRef: ScopedProjectRef,76 projectRef: ScopedProjectRef,
...@@ -105,6 +98,6 @@ export async function startNewLocalThreadFromContext(...@@ -105,6 +98,6 @@ export async function startNewLocalThreadFromContext(
105 return false;98 return false;
106 }99 }
107100
108 await context.handleNewThread(projectRef, buildDefaultThreadOptions(context));101 await context.handleNewThread(projectRef);
109 return true;102 return true;
110}103}
apps/web/src/routes/__root.tsx+2-2
...@@ -26,7 +26,7 @@ import {...@@ -26,7 +26,7 @@ import {
26 toastManager,26 toastManager,
27} from "../components/ui/toast";27} from "../components/ui/toast";
28import { resolveAndPersistPreferredEditor } from "../editorPreferences";28import { resolveAndPersistPreferredEditor } from "../editorPreferences";
29import { useSettings } from "../hooks/useSettings";29import { useClientSettings } from "../hooks/useSettings";
30import {30import {
31 deriveLogicalProjectKeyFromSettings,31 deriveLogicalProjectKeyFromSettings,
32 derivePhysicalProjectKeyFromPath,32 derivePhysicalProjectKeyFromPath,
...@@ -266,7 +266,7 @@ function AuthenticatedTracingBootstrap() {...@@ -266,7 +266,7 @@ function AuthenticatedTracingBootstrap() {
266function EventRouter() {266function EventRouter() {
267 const navigate = useNavigate();267 const navigate = useNavigate();
268 const pathname = useLocation({ select: (loc) => loc.pathname });268 const pathname = useLocation({ select: (loc) => loc.pathname });
269 const projectGroupingSettings = useSettings(selectProjectGroupingSettings);269 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
270 const primaryEnvironment = usePrimaryEnvironment();270 const primaryEnvironment = usePrimaryEnvironment();
271 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {271 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
272 reportFailure: false,272 reportFailure: false,
apps/web/src/routes/_chat.tsx-11
...@@ -16,9 +16,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina...@@ -16,9 +16,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
16import { isPreviewSupportedInRuntime } from "../previewStateStore";16import { isPreviewSupportedInRuntime } from "../previewStateStore";
17import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";17import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
18import { useThreadSelectionStore } from "../threadSelectionStore";18import { useThreadSelectionStore } from "../threadSelectionStore";
19import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic";
20import { stackedThreadToast, toastManager } from "~/components/ui/toast";19import { stackedThreadToast, toastManager } from "~/components/ui/toast";
21import { useSettings } from "~/hooks/useSettings";
22import { primaryServerKeybindingsAtom } from "~/state/server";20import { primaryServerKeybindingsAtom } from "~/state/server";
2321
24function ChatRouteGlobalShortcuts() {22function ChatRouteGlobalShortcuts() {
...@@ -40,8 +38,6 @@ function ChatRouteGlobalShortcuts() {...@@ -40,8 +38,6 @@ function ChatRouteGlobalShortcuts() {
40 ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"38 ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
41 : false,39 : false,
42 );40 );
43 const appSettings = useSettings();
44
45 useEffect(() => {41 useEffect(() => {
46 const onWindowKeyDown = (event: KeyboardEvent) => {42 const onWindowKeyDown = (event: KeyboardEvent) => {
47 if (event.defaultPrevented) return;43 if (event.defaultPrevented) return;
...@@ -71,9 +67,6 @@ function ChatRouteGlobalShortcuts() {...@@ -71,9 +67,6 @@ function ChatRouteGlobalShortcuts() {
71 activeDraftThread,67 activeDraftThread,
72 activeThread: activeThread ?? undefined,68 activeThread: activeThread ?? undefined,
73 defaultProjectRef,69 defaultProjectRef,
74 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
75 defaultEnvMode: appSettings.defaultThreadEnvMode,
76 }),
77 handleNewThread,70 handleNewThread,
78 });71 });
79 return;72 return;
...@@ -86,9 +79,6 @@ function ChatRouteGlobalShortcuts() {...@@ -86,9 +79,6 @@ function ChatRouteGlobalShortcuts() {
86 activeDraftThread,79 activeDraftThread,
87 activeThread: activeThread ?? undefined,80 activeThread: activeThread ?? undefined,
88 defaultProjectRef,81 defaultProjectRef,
89 defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
90 defaultEnvMode: appSettings.defaultThreadEnvMode,
91 }),
92 handleNewThread,82 handleNewThread,
93 });83 });
94 return;84 return;
...@@ -153,7 +143,6 @@ function ChatRouteGlobalShortcuts() {...@@ -153,7 +143,6 @@ function ChatRouteGlobalShortcuts() {
153 routeThreadRef,143 routeThreadRef,
154 selectedThreadKeysSize,144 selectedThreadKeysSize,
155 terminalOpen,145 terminalOpen,
156 appSettings.defaultThreadEnvMode,
157 ]);146 ]);
158147
159 return null;148 return null;
apps/web/src/state/entities.ts+6
...@@ -12,12 +12,14 @@ import type {...@@ -12,12 +12,14 @@ import type {
12 OrchestrationThreadActivity,12 OrchestrationThreadActivity,
13 ScopedProjectRef,13 ScopedProjectRef,
14 ScopedThreadRef,14 ScopedThreadRef,
15 ServerConfig,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import type { EnvironmentId, ThreadId } from "@t3tools/contracts";17import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
17import { Atom } from "effect/unstable/reactivity";18import { Atom } from "effect/unstable/reactivity";
18import { useMemo } from "react";19import { useMemo } from "react";
19import { appAtomRegistry } from "../rpc/atomRegistry";20import { appAtomRegistry } from "../rpc/atomRegistry";
20import { environmentProjects } from "./projects";21import { environmentProjects } from "./projects";
22import { environmentServerConfigsAtom } from "./server";
21import { environmentThreadDetails, environmentThreadShells } from "./threads";23import { environmentThreadDetails, environmentThreadShells } from "./threads";
2224
23const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);25const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
...@@ -103,6 +105,10 @@ export function useProjects(): ReadonlyArray<EnvironmentProject> {...@@ -103,6 +105,10 @@ export function useProjects(): ReadonlyArray<EnvironmentProject> {
103 return useAtomValue(environmentProjects.projectsAtom);105 return useAtomValue(environmentProjects.projectsAtom);
104}106}
105107
108export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
109 return useAtomValue(environmentServerConfigsAtom);
110}
111
106export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {112export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
107 return useAtomValue(environmentThreadShells.threadShellsAtom);113 return useAtomValue(environmentThreadShells.threadShellsAtom);
108}114}
apps/web/src/state/environments.ts+1-10
...@@ -5,11 +5,11 @@ import {...@@ -5,11 +5,11 @@ import {
5} from "@t3tools/client-runtime/connection";5} from "@t3tools/client-runtime/connection";
6import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import { Atom } from "effect/unstable/reactivity";
9import { useMemo } from "react";8import { useMemo } from "react";
109
11import { environmentCatalog } from "../connection/catalog";10import { environmentCatalog } from "../connection/catalog";
12import { environmentPresentations, useEnvironmentPresentation } from "./presentation";11import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
12import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
13import { useEnvironmentQuery } from "./query";13import { useEnvironmentQuery } from "./query";
14import { relayEnvironmentDiscovery } from "./relay";14import { relayEnvironmentDiscovery } from "./relay";
15import { usePreparedConnection } from "./session";15import { usePreparedConnection } from "./session";
...@@ -21,15 +21,6 @@ export interface EnvironmentPresentation extends BaseEnvironmentPresentation {...@@ -21,15 +21,6 @@ export interface EnvironmentPresentation extends BaseEnvironmentPresentation {
21 readonly relayManaged: boolean;21 readonly relayManaged: boolean;
22}22}
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
33function projectEnvironmentPresentation(24function projectEnvironmentPresentation(
34 environmentId: EnvironmentId,25 environmentId: EnvironmentId,
35 presentation: BaseEnvironmentPresentation,26 presentation: BaseEnvironmentPresentation,
apps/web/src/state/presentation.ts+2-2
...@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";...@@ -5,12 +5,12 @@ import type { EnvironmentId } from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
66
7import { environmentCatalog } from "../connection/catalog";7import { environmentCatalog } from "../connection/catalog";
8import { environmentSession } from "./session";8import { serverEnvironment } from "./server";
99
10export const environmentPresentations = createEnvironmentPresentationAtoms({10export const environmentPresentations = createEnvironmentPresentationAtoms({
11 catalogValueAtom: environmentCatalog.catalogValueAtom,11 catalogValueAtom: environmentCatalog.catalogValueAtom,
12 stateAtom: environmentCatalog.stateAtom,12 stateAtom: environmentCatalog.stateAtom,
13 configValueAtom: environmentSession.configValueAtom,13 serverConfigValueAtom: serverEnvironment.configValueAtom,
14});14});
1515
16const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe(16const 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";...@@ -15,15 +15,15 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
1515
16import { environmentCatalog } from "../connection/catalog";16import { environmentCatalog } from "../connection/catalog";
17import { connectionAtomRuntime } from "../connection/runtime";17import { connectionAtomRuntime } from "../connection/runtime";
18import { primaryEnvironmentIdAtom } from "./environments";18import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
19import { environmentSession } from "./session";19import { environmentSession } from "./session";
2020
21export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {21export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, {
22 initialConfigValueAtom: environmentSession.configValueAtom,22 initialConfigValueAtom: environmentSession.initialConfigValueAtom,
23});23});
24export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({24export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
25 catalogValueAtom: environmentCatalog.catalogValueAtom,25 catalogValueAtom: environmentCatalog.catalogValueAtom,
26 configValueAtom: serverEnvironment.configValueAtom,26 serverConfigValueAtom: serverEnvironment.configValueAtom,
27});27});
2828
29interface PrimaryServerState {29interface PrimaryServerState {
packages/client-runtime/src/state/presentation.ts+3-2
...@@ -26,7 +26,8 @@ export function createEnvironmentPresentationAtoms<E>(input: {...@@ -26,7 +26,8 @@ export function createEnvironmentPresentationAtoms<E>(input: {
26 readonly stateAtom: (26 readonly stateAtom: (
27 environmentId: EnvironmentId,27 environmentId: EnvironmentId,
28 ) => Atom.Atom<AsyncResult.AsyncResult<SupervisorConnectionState, E>>;28 ) => 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>;
30}) {31}) {
31 const presentationAtom = Atom.family((environmentId: EnvironmentId) =>32 const presentationAtom = Atom.family((environmentId: EnvironmentId) =>
32 Atom.make((get) => {33 Atom.make((get) => {
...@@ -41,7 +42,7 @@ export function createEnvironmentPresentationAtoms<E>(input: {...@@ -41,7 +42,7 @@ export function createEnvironmentPresentationAtoms<E>(input: {
41 return {42 return {
42 entry,43 entry,
43 connection: presentEnvironmentConnection(state),44 connection: presentEnvironmentConnection(state),
44 serverConfig: get(input.configValueAtom(environmentId)),45 serverConfig: get(input.serverConfigValueAtom(environmentId)),
45 } satisfies EnvironmentPresentation;46 } satisfies EnvironmentPresentation;
46 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),47 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),
47 );48 );
packages/client-runtime/src/state/projectGrouping.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import type { UnifiedSettings } from "@t3tools/contracts/settings";3import type { ClientSettings } from "@t3tools/contracts/settings";
44
5import type { EnvironmentProject } from "./models.ts";5import type { EnvironmentProject } from "./models.ts";
6import { normalizeProjectPathForComparison } from "./projects.ts";6import { normalizeProjectPathForComparison } from "./projects.ts";
...@@ -12,7 +12,7 @@ export interface ProjectGroupingSettings {...@@ -12,7 +12,7 @@ export interface ProjectGroupingSettings {
1212
13export type ProjectGroupingMode = SidebarProjectGroupingMode;13export type ProjectGroupingMode = SidebarProjectGroupingMode;
1414
15export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {15export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
16 return {16 return {
17 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,17 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
18 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,18 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
packages/client-runtime/src/state/server.ts+12
...@@ -118,9 +118,21 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -118,9 +118,21 @@ export function createServerEnvironmentAtoms<R, E>(
118 return projection?.config ?? get(options.initialConfigValueAtom(environmentId));118 return projection?.config ?? get(options.initialConfigValueAtom(environmentId));
119 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));119 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
120 });120 });
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
122 return {132 return {
123 configValueAtom,133 configValueAtom,
134 settingsValueAtom,
135 providersValueAtom,
124 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {136 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
125 label: "environment-data:server:trace-diagnostics",137 label: "environment-data:server:trace-diagnostics",
126 tag: WS_METHODS.serverGetTraceDiagnostics,138 tag: WS_METHODS.serverGetTraceDiagnostics,
packages/client-runtime/src/state/session.ts+10-5
...@@ -26,7 +26,7 @@ export function initialConfigOption<E>(...@@ -26,7 +26,7 @@ export function initialConfigOption<E>(
26export function createEnvironmentSessionAtoms<R, E>(26export function createEnvironmentSessionAtoms<R, E>(
27 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,27 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
28) {28) {
29 const configAtom = Atom.family((environmentId: EnvironmentId) =>29 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
30 runtime.atom(30 runtime.atom(
31 followStreamInEnvironment(31 followStreamInEnvironment(
32 environmentId,32 environmentId,
...@@ -49,10 +49,15 @@ export function createEnvironmentSessionAtoms<R, E>(...@@ -49,10 +49,15 @@ export function createEnvironmentSessionAtoms<R, E>(
49 ),49 ),
50 );50 );
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) =>
53 Atom.make((get): ServerConfig | null =>56 Atom.make((get): ServerConfig | null =>
54 Option.getOrNull(57 Option.getOrNull(
55 Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()),58 Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () =>
59 Option.none(),
60 ),
56 ),61 ),
57 ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)),62 ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)),
58 );63 );
...@@ -80,8 +85,8 @@ export function createEnvironmentSessionAtoms<R, E>(...@@ -80,8 +85,8 @@ export function createEnvironmentSessionAtoms<R, E>(
80 );85 );
8186
82 return {87 return {
83 configAtom,88 initialConfigAtom,
84 configValueAtom,89 initialConfigValueAtom,
85 preparedConnectionAtom,90 preparedConnectionAtom,
86 preparedConnectionValueAtom,91 preparedConnectionValueAtom,
87 };92 };
packages/client-runtime/src/state/shell.test.ts+1-1
...@@ -75,7 +75,7 @@ function makeHarness() {...@@ -75,7 +75,7 @@ function makeHarness() {
75 });75 });
76 const serverConfigsAtom = createEnvironmentServerConfigsAtom({76 const serverConfigsAtom = createEnvironmentServerConfigsAtom({
77 catalogValueAtom,77 catalogValueAtom,
78 configValueAtom: configAtoms,78 serverConfigValueAtom: configAtoms,
79 });79 });
8080
81 return {81 return {
packages/client-runtime/src/state/shell.ts+2-2
...@@ -268,13 +268,13 @@ export function createEnvironmentShellSummaryAtom(input: {...@@ -268,13 +268,13 @@ export function createEnvironmentShellSummaryAtom(input: {
268268
269export function createEnvironmentServerConfigsAtom(input: {269export function createEnvironmentServerConfigsAtom(input: {
270 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;270 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
271 readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;271 readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
272}) {272}) {
273 let previousServerConfigs = EMPTY_SERVER_CONFIGS;273 let previousServerConfigs = EMPTY_SERVER_CONFIGS;
274 return Atom.make((get) => {274 return Atom.make((get) => {
275 const next = new Map<EnvironmentId, ServerConfig>();275 const next = new Map<EnvironmentId, ServerConfig>();
276 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {276 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
277 const config = get(input.configValueAtom(environmentId));277 const config = get(input.serverConfigValueAtom(environmentId));
278 if (config !== null) {278 if (config !== null) {
279 next.set(environmentId, config);279 next.set(environmentId, config);
280 }280 }