authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 13:56:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 13:56:40-07:00
loge22040dfc190d04573238c5db2e69df72e5f1017
treea0b076797d4400e90a0b0e89898553ee3b4e792e
parent8b2c0465def151f022e01dd04b1b6ba3859bd714
signature Signed by PGP key B5690EEEBB952194

feat(settings): edit any scopable setting as a project override (#10639)


71 files changed, 5300 insertions(+), 2675 deletions(-)

apps/mobile/src/features/settings/SettingsRouteScreen.tsx+15-37
...@@ -41,14 +41,8 @@ import {...@@ -41,14 +41,8 @@ import {
41 DEFAULT_SERVER_SETTINGS,41 DEFAULT_SERVER_SETTINGS,
42 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,42 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
43 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,43 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
44 type ServerSettingsPatch,
45} from "@t3tools/contracts";44} from "@t3tools/contracts";
46import {45import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
47 filterSharedServerPatch,
48 findSharedSettingsMismatches,
49 pickSharedServerSettings,
50 supportsSharedSettingsSync,
51} from "@t3tools/client-runtime/state/shared-settings";
52import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";46import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
53import {47import {
54 type AppUpdateCheckState,48 type AppUpdateCheckState,
...@@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow";...@@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow";
61import { SettingsSection } from "./components/SettingsSection";55import { SettingsSection } from "./components/SettingsSection";
62import { SettingsSwitchRow } from "./components/SettingsSwitchRow";56import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
63import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";57import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
58import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
6459
65type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";60type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
66type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";61type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
...@@ -588,10 +583,9 @@ function GeneralSettingsSection() {...@@ -588,10 +583,9 @@ function GeneralSettingsSection() {
588const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;583const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
589584
590/**585/**
591 * Auto-settlement is a user preference that every server has to hold. Mobile586 * Mobile edits auto-settle defaults across connected, capable environments.
592 * has no primary environment, so the first eligible sync target provides the587 * The first target supplies the displayed values. Applying them leaves each
593 * reference value. Edits fan out to every eligible target, and a mismatch row588 * environment's other defaults and overrides intact.
594 * lets the user push the reference out.
595 */589 */
596function AutoSettleSettingsRows() {590function AutoSettleSettingsRows() {
597 const { environments } = useEnvironments();591 const { environments } = useEnvironments();
...@@ -610,24 +604,20 @@ function AutoSettleSettingsRows() {...@@ -610,24 +604,20 @@ function AutoSettleSettingsRows() {
610 return null;604 return null;
611 }605 }
612606
613 const writeToAll = (patch: ServerSettingsPatch) => {607 const writeToAll = (patch: Partial<AutoSettleSettings>) => {
614 for (const environment of syncTargets) {608 for (const environment of syncTargets) {
615 void updateSettings({ environmentId: environment.environmentId, input: { patch } });609 void updateSettings({ environmentId: environment.environmentId, input: { patch } });
616 }610 }
617 };611 };
618612
619 const mismatches = findSharedSettingsMismatches({613 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
620 primaryEnvironmentId: reference.environmentId,614 { environmentId: reference.environmentId, settings: referenceSettings },
621 primarySettings: referenceSettings,615 syncTargets.map((environment) => ({
622 primaryCapabilities: reference.serverConfig?.environment.capabilities,
623 environments: environments.map((environment) => ({
624 environmentId: environment.environmentId,616 environmentId: environment.environmentId,
625 label: environment.label,617 label: environment.label,
626 syncEligible: supportsSharedSettingsSync(environment),
627 settings: environment.serverConfig?.settings ?? null,618 settings: environment.serverConfig?.settings ?? null,
628 capabilities: environment.serverConfig?.environment.capabilities,
629 })),619 })),
630 });620 );
631621
632 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;622 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
633 const commitDays = () => {623 const commitDays = () => {
...@@ -681,7 +671,7 @@ function AutoSettleSettingsRows() {...@@ -681,7 +671,7 @@ function AutoSettleSettingsRows() {
681 {mismatches.length > 0 ? (671 {mismatches.length > 0 ? (
682 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">672 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
683 <View className="min-w-0 flex-1">673 <View className="min-w-0 flex-1">
684 <Text className="text-lg text-foreground">Settings differ</Text>674 <Text className="text-lg text-foreground">Auto-settle defaults differ</Text>
685 <Text className="text-sm text-foreground-muted">675 <Text className="text-sm text-foreground-muted">
686 {mismatches.map((mismatch) => mismatch.label).join(", ")}676 {mismatches.map((mismatch) => mismatch.label).join(", ")}
687 </Text>677 </Text>
...@@ -689,30 +679,18 @@ function AutoSettleSettingsRows() {...@@ -689,30 +679,18 @@ function AutoSettleSettingsRows() {
689 <Pressable679 <Pressable
690 accessibilityRole="button"680 accessibilityRole="button"
691 onPress={() => {681 onPress={() => {
692 const patch = pickSharedServerSettings(
693 referenceSettings,
694 reference.serverConfig?.environment.capabilities,
695 );
696 for (const mismatch of mismatches) {682 for (const mismatch of mismatches) {
697 const target = environments.find(
698 (candidate) => candidate.environmentId === mismatch.environmentId,
699 );
700 void updateSettings({683 void updateSettings({
701 environmentId: mismatch.environmentId,684 environmentId: mismatch.environmentId,
702 input: {685 input: { patch: autoSettlePatch },
703 patch: filterSharedServerPatch(
704 patch,
705 target?.serverConfig?.environment.capabilities,
706 target?.serverConfig?.settings,
707 referenceSettings,
708 ),
709 },
710 });686 });
711 }687 }
712 }}688 }}
713 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"689 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
714 >690 >
715 <Text className="text-base font-t3-medium text-foreground">Apply to all</Text>691 <Text className="text-base font-t3-medium text-foreground">
692 Apply auto-settle defaults
693 </Text>
716 </Pressable>694 </Pressable>
717 </View>695 </View>
718 ) : null}696 ) : null}
apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts created+78
...@@ -0,0 +1,78 @@
1import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync";
5
6const reference = {
7 environmentId: EnvironmentId.make("reference"),
8 settings: {
9 ...DEFAULT_SERVER_SETTINGS,
10 sidebarAutoSettleAfterDays: 7,
11 sidebarAutoSettleOnMerge: true,
12 newWorktreesStartFromOrigin: false,
13 continueThreadsAfterServerUpdate: false,
14 },
15};
16
17describe("auto-settle settings sync", () => {
18 it("ignores differences in independently configured environment settings", () => {
19 const target = {
20 environmentId: EnvironmentId.make("remote"),
21 label: "Remote",
22 settings: {
23 ...reference.settings,
24 newWorktreesStartFromOrigin: true,
25 continueThreadsAfterServerUpdate: true,
26 sourceControlWritingStyle: {
27 ...reference.settings.sourceControlWritingStyle,
28 customInstructions: "Keep this environment's writing instructions.",
29 },
30 },
31 };
32
33 const plan = planAutoSettleSettingsSync(reference, [target]);
34
35 expect(plan.mismatches).toEqual([]);
36 expect(plan.patch).toEqual({
37 sidebarAutoSettleAfterDays: 7,
38 sidebarAutoSettleOnMerge: true,
39 });
40 });
41
42 it("applies only auto-settle defaults when another environment differs", () => {
43 const target = {
44 environmentId: EnvironmentId.make("remote"),
45 label: "Remote",
46 settings: {
47 ...reference.settings,
48 sidebarAutoSettleAfterDays: null,
49 sidebarAutoSettleOnMerge: false,
50 newWorktreesStartFromOrigin: true,
51 continueThreadsAfterServerUpdate: true,
52 sourceControlWritingStyle: {
53 ...reference.settings.sourceControlWritingStyle,
54 customInstructions: "Preserve these instructions.",
55 },
56 },
57 };
58
59 const plan = planAutoSettleSettingsSync(reference, [target]);
60 const updated = { ...target.settings, ...plan.patch };
61
62 expect(plan.mismatches).toEqual([target]);
63 expect(updated.sidebarAutoSettleAfterDays).toBe(7);
64 expect(updated.sidebarAutoSettleOnMerge).toBe(true);
65 expect(updated.newWorktreesStartFromOrigin).toBe(true);
66 expect(updated.continueThreadsAfterServerUpdate).toBe(true);
67 expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle);
68 });
69
70 it("does not compare the reference or a target without loaded settings", () => {
71 const plan = planAutoSettleSettingsSync(reference, [
72 { ...reference, label: "Reference" },
73 { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null },
74 ]);
75
76 expect(plan.mismatches).toEqual([]);
77 });
78});
apps/mobile/src/features/settings/autoSettleSettingsSync.ts created+31
...@@ -0,0 +1,31 @@
1import type { EnvironmentId, ServerSettings } from "@t3tools/contracts";
2
3export type AutoSettleSettings = Pick<
4 ServerSettings,
5 "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge"
6>;
7
8interface AutoSettleSyncTarget {
9 readonly environmentId: EnvironmentId;
10 readonly label: string;
11 readonly settings: AutoSettleSettings | null;
12}
13
14/** Receives connected, capable targets. Applying these defaults must preserve other settings. */
15export function planAutoSettleSettingsSync(
16 reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings },
17 targets: readonly AutoSettleSyncTarget[],
18) {
19 const patch: AutoSettleSettings = {
20 sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays,
21 sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge,
22 };
23 const mismatches = targets.filter(
24 (target) =>
25 target.environmentId !== reference.environmentId &&
26 target.settings !== null &&
27 (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays ||
28 target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge),
29 );
30 return { patch, mismatches };
31}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+22-9
...@@ -13,10 +13,12 @@ import {...@@ -13,10 +13,12 @@ import {
13 CommandId,13 CommandId,
14 DEFAULT_PROVIDER_INTERACTION_MODE,14 DEFAULT_PROVIDER_INTERACTION_MODE,
15 DEFAULT_RUNTIME_MODE,15 DEFAULT_RUNTIME_MODE,
16 DEFAULT_SERVER_SETTINGS,
16 MessageId,17 MessageId,
17 T3_PROJECT_FILE_NAME,18 T3_PROJECT_FILE_NAME,
18 ThreadId,19 ThreadId,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
20import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";22import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
21import {23import {
22 isDefaultThreadEnvModeSettled,24 isDefaultThreadEnvModeSettled,
...@@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
428 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;430 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
429 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;431 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
430 }, [t3ProjectFileData]);432 }, [t3ProjectFileData]);
433 // Environment settings with the project's overrides applied; the
434 // aggregate's own legacy fields still count until the server folds them.
435 const projectSettings = useMemo(
436 () =>
437 resolveProjectSettings(
438 selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
439 selectedProject?.id ?? null,
440 selectedProject,
441 ),
442 [selectedEnvironmentServerConfig?.settings, selectedProject],
443 );
444 const projectThreadEnvMode =
445 projectSettings.sources.defaultThreadEnvMode === "project"
446 ? projectSettings.settings.defaultThreadEnvMode
447 : undefined;
431 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({448 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
432 projectSetting: selectedProject?.defaultThreadEnvMode,449 projectSetting: projectThreadEnvMode,
433 projectFile: t3ProjectFileDefaultMode,450 projectFile: t3ProjectFileDefaultMode,
434 globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local",451 globalDefault: projectSettings.settings.defaultThreadEnvMode,
435 });452 });
436 // While unsettled the resolved default is provisional. Nothing may write453 // While unsettled the resolved default is provisional. Nothing may write
437 // it into the draft during that window (the auto-branch effect does), or454 // it into the draft during that window (the auto-branch effect does), or
438 // the frozen interim value beats the t3.json default once it loads.455 // the frozen interim value beats the t3.json default once it loads.
439 const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({456 const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({
440 explicitMode: selectedProjectDraft.workspaceSelection?.mode,457 explicitMode: selectedProjectDraft.workspaceSelection?.mode,
441 projectSetting: selectedProject?.defaultThreadEnvMode,458 projectSetting: projectThreadEnvMode,
442 projectFilePending: t3ProjectFileQuery.isPending,459 projectFilePending: t3ProjectFileQuery.isPending,
443 });460 });
444 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;461 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
...@@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
449 // value keeps tracking the server setting when the config loads late.466 // value keeps tracking the server setting when the config loads late.
450 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;467 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
451 const startFromOrigin =468 const startFromOrigin =
452 draftStartFromOrigin ??469 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
453 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
454 true;
455 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;470 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
456471
457 // Antigravity keeps unavailable selections so sign-out or a catalog change472 // Antigravity keeps unavailable selections so sign-out or a catalog change
...@@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
463 );478 );
464 const projectDefaultModelSelection = resolveDefaultableModelSelection(479 const projectDefaultModelSelection = resolveDefaultableModelSelection(
465 selectedEnvironmentServerConfig,480 selectedEnvironmentServerConfig,
466 selectedProject?.defaultModelSelection ??481 projectSettings.settings.defaultModelSelection,
467 selectedEnvironmentServerConfig?.settings.defaultModelSelection ??
468 null,
469 );482 );
470 const storedStickyModelSelection = useStickyComposerModelSelection();483 const storedStickyModelSelection = useStickyComposerModelSelection();
471 const stickyModelSelection = resolveDefaultableModelSelection(484 const stickyModelSelection = resolveDefaultableModelSelection(
apps/web/src/components/ChatView.tsx+42-22
...@@ -68,6 +68,7 @@ import {...@@ -68,6 +68,7 @@ import {
68 projectScriptRuntimeEnv,68 projectScriptRuntimeEnv,
69 resolveProjectScripts,69 resolveProjectScripts,
70} from "@t3tools/shared/projectScripts";70} from "@t3tools/shared/projectScripts";
71import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
71import { truncate } from "@t3tools/shared/String";72import { truncate } from "@t3tools/shared/String";
72import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";73import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
73import {74import {
...@@ -304,7 +305,6 @@ import {...@@ -304,7 +305,6 @@ import {
304 environmentServerConfigsAtom,305 environmentServerConfigsAtom,
305 primaryServerAvailableEditorsAtom,306 primaryServerAvailableEditorsAtom,
306 primaryServerKeybindingsAtom,307 primaryServerKeybindingsAtom,
307 primaryServerSettingsAtom,
308 serverEnvironment,308 serverEnvironment,
309} from "../state/server";309} from "../state/server";
310import { terminalEnvironment } from "../state/terminal";310import { terminalEnvironment } from "../state/terminal";
...@@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) {
1516 }, [routeKind, routeThreadRef, routeThreadState]);1516 }, [routeKind, routeThreadRef, routeThreadState]);
1517 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1517 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1518 const settings = useEnvironmentSettings(environmentId);1518 const settings = useEnvironmentSettings(environmentId);
1519 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
1520 const setStickyComposerModelSelection = useComposerDraftStore(1519 const setStickyComposerModelSelection = useComposerDraftStore(
1521 (store) => store.setStickyModelSelection,1520 (store) => store.setStickyModelSelection,
1522 );1521 );
...@@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) {
1802 ? buildLocalDraftThread(1801 ? buildLocalDraftThread(
1803 threadId,1802 threadId,
1804 draftThread,1803 draftThread,
1805 fallbackDraftProject?.defaultModelSelection ??1804 resolveProjectSettings(
1806 settings.defaultModelSelection ??1805 settings,
1807 NO_PROVIDER_MODEL_SELECTION,1806 fallbackDraftProject?.id ?? null,
1807 fallbackDraftProject ?? undefined,
1808 ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
1808 )1809 )
1809 : undefined,1810 : undefined,
1810 [1811 [draftThread, fallbackDraftProject, settings, threadId],
1811 draftThread,
1812 fallbackDraftProject?.defaultModelSelection,
1813 settings.defaultModelSelection,
1814 threadId,
1815 ],
1816 );1812 );
1817 // Promotion is data-driven: the draft route keeps rendering while the1813 // Promotion is data-driven: the draft route keeps rendering while the
1818 // server thread (same pre-allocated ref) starts, so live state must not1814 // server thread (same pre-allocated ref) starts, so live state must not
...@@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) {
2033 [activeThread?.environmentId, activeThread?.projectId],2029 [activeThread?.environmentId, activeThread?.projectId],
2034 );2030 );
2035 const activeProject = useProject(activeProjectRef);2031 const activeProject = useProject(activeProjectRef);
2032 // Environment settings with the active project's overrides applied.
2033 const activeProjectSettings = useMemo(
2034 () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined),
2035 [activeProject, settings],
2036 );
2036 const activeProjectScripts = useMemo(2037 const activeProjectScripts = useMemo(
2037 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),2038 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
2038 [activeProject, settings],2039 [activeProject, settings],
2039 );2040 );
2040 const activeProjectDefaultModelSelection =2041 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
2041 activeProject?.defaultModelSelection ?? settings.defaultModelSelection;
2042 const handleNewThreadInActiveProject = useCallback(() => {2042 const handleNewThreadInActiveProject = useCallback(() => {
2043 startNewThreadForProject(activeProjectRef, handleNewThread);2043 startNewThreadForProject(activeProjectRef, handleNewThread);
2044 }, [activeProjectRef, handleNewThread]);2044 }, [activeProjectRef, handleNewThread]);
...@@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) {
3945 ],3945 ],
3946 );3946 );
39473947
3948 const supportsProjectSettingsOverrides =
3949 environmentById.get(environmentId)?.serverConfig?.environment.capabilities
3950 .projectSettingsOverrides === true;
3948 const persistProjectScripts = useCallback(3951 const persistProjectScripts = useCallback(
3949 async (input: {3952 async (input: {
3950 projectId: ProjectId;3953 projectId: ProjectId;
...@@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) {
3958 await updateProjectScriptSettings({3961 await updateProjectScriptSettings({
3959 environmentId,3962 environmentId,
3960 input: {3963 input: {
3961 patch: {3964 // The canonical key on servers that understand it; the legacy
3962 projectScriptOverrides: {3965 // per-project map is still translated on older ones.
3963 [input.projectId]: input.nextScripts,3966 patch: supportsProjectSettingsOverrides
3964 },3967 ? {
3965 },3968 projectSettingsOverrides: {
3969 [input.projectId]: {
3970 ...settings.projectSettingsOverrides[input.projectId],
3971 defaultProjectScripts: input.nextScripts,
3972 },
3973 },
3974 }
3975 : {
3976 projectScriptOverrides: {
3977 [input.projectId]: input.nextScripts,
3978 },
3979 },
3966 },3980 },
3967 }),3981 }),
3968 () => undefined,3982 () => undefined,
...@@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) {
3987 }4001 }
3988 return updateResult;4002 return updateResult;
3989 },4003 },
3990 [environmentId, updateProjectScriptSettings, upsertKeybinding],4004 [
4005 environmentId,
4006 settings.projectSettingsOverrides,
4007 supportsProjectSettingsOverrides,
4008 updateProjectScriptSettings,
4009 upsertKeybinding,
4010 ],
3991 );4011 );
3992 const saveProjectScript = useCallback(4012 const saveProjectScript = useCallback(
3993 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {4013 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
...@@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) {
5396 ? (draftThread?.startFromOrigin ?? false)5416 ? (draftThread?.startFromOrigin ?? false)
5397 : canOverrideServerThreadEnvMode5417 : canOverrideServerThreadEnvMode
5398 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??5418 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
5399 primaryServerSettings.newWorktreesStartFromOrigin)5419 activeProjectSettings.settings.newWorktreesStartFromOrigin)
5400 : false;5420 : false;
5401 const sendEnvMode = resolveSendEnvMode({5421 const sendEnvMode = resolveSendEnvMode({
5402 requestedEnvMode: envMode,5422 requestedEnvMode: envMode,
...@@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) {
8045 envMode: mode,8065 envMode: mode,
8046 startFromOrigin: resolveNewDraftStartFromOrigin({8066 startFromOrigin: resolveNewDraftStartFromOrigin({
8047 envMode: mode,8067 envMode: mode,
8048 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,8068 newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
8049 }),8069 }),
8050 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),8070 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
8051 });8071 });
...@@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) {
8057 composerDraftTarget,8077 composerDraftTarget,
8058 draftThread?.worktreePath,8078 draftThread?.worktreePath,
8059 isLocalDraftThread,8079 isLocalDraftThread,
8060 primaryServerSettings.newWorktreesStartFromOrigin,8080 activeProjectSettings.settings.newWorktreesStartFromOrigin,
8061 setPendingServerThreadEnvMode,8081 setPendingServerThreadEnvMode,
8062 scheduleComposerFocus,8082 scheduleComposerFocus,
8063 setDraftThreadContext,8083 setDraftThreadContext,
apps/web/src/components/CommandPalette.tsx+1-4
...@@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: {
1814 },1814 },
1815 });1815 });
18161816
1817 // There is no projects listing page; the action targets the contextual1817 // Target the active thread or draft's project, falling back to the first sidebar group.
1818 // project (active thread/draft, falling back to the first sidebar group).
1819 const contextualProjectGroup =1818 const contextualProjectGroup =
1820 (contextualProjectRef1819 (contextualProjectRef
1821 ? projectGroupByTargetKey.get(1820 ? projectGroupByTargetKey.get(
...@@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: {...@@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: {
1867 run: async () => {1866 run: async () => {
1868 await navigate({1867 await navigate({
1869 to: item.to,1868 to: item.to,
1870 search: (previous) =>
1871 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
1872 hash: item.targetId ?? item.id,1869 hash: item.targetId ?? item.id,
1873 replace: pathname === item.to,1870 replace: pathname === item.to,
1874 hashScrollIntoView: false,1871 hashScrollIntoView: false,
apps/web/src/components/chat/DraftHeroHeadline.tsx+8-5
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 MenuTrigger,27 MenuTrigger,
28} from "../ui/menu";28} from "../ui/menu";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3031
31interface DraftHeroHeadlineProps {32interface DraftHeroHeadlineProps {
32 readonly draftId: DraftId | null;33 readonly draftId: DraftId | null;
...@@ -150,11 +151,13 @@ export function DraftHeroHeadline({...@@ -150,11 +151,13 @@ export function DraftHeroHeadline({
150 );151 );
151 if (!hasExplicitComposerModelSelection(currentDraft)) {152 if (!hasExplicitComposerModelSelection(currentDraft)) {
152 applyStickyState(draftId);153 applyStickyState(draftId);
153 const defaultModelSelection =154 const environmentSettings = environments.find(
154 project.defaultModelSelection ??155 (environment) => environment.environmentId === project.environmentId,
155 environments.find(156 )?.serverConfig?.settings;
156 (environment) => environment.environmentId === project.environmentId,157 const defaultModelSelection = environmentSettings
157 )?.serverConfig?.settings.defaultModelSelection;158 ? resolveProjectSettings(environmentSettings, project.id, project).settings
159 .defaultModelSelection
160 : project.defaultModelSelection;
158 if (defaultModelSelection) {161 if (defaultModelSelection) {
159 setModelSelection(draftId, defaultModelSelection, {162 setModelSelection(draftId, defaultModelSelection, {
160 replaceOptions: true,163 replaceOptions: true,
apps/web/src/components/chat/ProviderModelPicker.test.tsx+15
...@@ -34,6 +34,7 @@ function renderPicker(input: {...@@ -34,6 +34,7 @@ function renderPicker(input: {
34 model: string;34 model: string;
35 options: ReadonlyArray<ModelEsque>;35 options: ReadonlyArray<ModelEsque>;
36 includeEntry?: boolean;36 includeEntry?: boolean;
37 triggerLabel?: string;
37}) {38}) {
38 const instanceId = ProviderInstanceId.make(input.instanceId);39 const instanceId = ProviderInstanceId.make(input.instanceId);
39 const entry = providerEntry(input.instanceId, input.driver);40 const entry = providerEntry(input.instanceId, input.driver);
...@@ -45,11 +46,25 @@ function renderPicker(input: {...@@ -45,11 +46,25 @@ function renderPicker(input: {
45 instanceEntries={input.includeEntry === false ? [] : [entry]}46 instanceEntries={input.includeEntry === false ? [] : [entry]}
46 modelOptionsByInstance={new Map([[instanceId, input.options]])}47 modelOptionsByInstance={new Map([[instanceId, input.options]])}
47 onInstanceModelChange={() => {}}48 onInstanceModelChange={() => {}}
49 {...(input.triggerLabel ? { triggerLabel: input.triggerLabel } : {})}
48 />,50 />,
49 );51 );
50}52}
5153
52describe("ProviderModelPicker", () => {54describe("ProviderModelPicker", () => {
55 it("shows a neutral aggregate value without a representative model or availability badge", () => {
56 const markup = renderPicker({
57 instanceId: "codex_personal",
58 driver: "codex",
59 model: "gpt-5",
60 options: [{ slug: "gpt-5", name: "GPT 5", isUnavailable: true }],
61 triggerLabel: "Mixed values",
62 });
63 expect(markup).toContain("Mixed values");
64 expect(markup).not.toContain("GPT 5");
65 expect(markup).not.toContain("Unavailable");
66 });
67
53 it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(68 it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(
54 "shows a choice prompt before Antigravity has an account catalog for %s",69 "shows a choice prompt before Antigravity has an account catalog for %s",
55 (model) => {70 (model) => {
apps/web/src/components/chat/ProviderModelPicker.tsx+6-4
...@@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
48 open?: boolean;48 open?: boolean;
49 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];49 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
50 triggerClassName?: string;50 triggerClassName?: string;
51 /** Aggregate settings can show a neutral value without claiming one provider is selected. */
52 triggerLabel?: string;
51 triggerAriaLabel?: string;53 triggerAriaLabel?: string;
52 onOpenChange?: (open: boolean) => void;54 onOpenChange?: (open: boolean) => void;
53 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;55 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
...@@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
181 <span183 <span
182 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}184 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
183 >185 >
184 {activeEntry ? (186 {activeEntry && props.triggerLabel === undefined ? (
185 <ProviderInstanceIcon187 <ProviderInstanceIcon
186 driverKind={activeEntry.driverKind}188 driverKind={activeEntry.driverKind}
187 displayName={activeEntry.displayName}189 displayName={activeEntry.displayName}
...@@ -205,11 +207,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -205,11 +207,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
205 />207 />
206 }208 }
207 >209 >
208 {triggerTitle}210 {props.triggerLabel ?? triggerTitle}
209 </TooltipTrigger>211 </TooltipTrigger>
210 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>212 <TooltipPopup side="top">{props.triggerLabel ?? triggerLabel}</TooltipPopup>
211 </Tooltip>213 </Tooltip>
212 {selectedModel?.isUnavailable ? (214 {selectedModel?.isUnavailable && props.triggerLabel === undefined ? (
213 <Badge variant="outline" size="sm">215 <Badge variant="outline" size="sm">
214 Unavailable216 Unavailable
215 </Badge>217 </Badge>
apps/web/src/components/projectScriptEditor.test.tsx created+245
...@@ -0,0 +1,245 @@
1import * as Cause from "effect/Cause";
2import { AsyncResult } from "effect/unstable/reactivity";
3import { act, StrictMode, type ReactNode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7vi.mock("./ui/dialog", () => ({
8 Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
9 DialogDescription: "p",
10 DialogFooter: "footer",
11 DialogHeader: "header",
12 DialogPanel: "section",
13 DialogPopup: "section",
14 DialogTitle: "h2",
15}));
16vi.mock("./ui/alert-dialog", () => ({
17 AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
18 open ? children : null,
19 AlertDialogClose: "button",
20 AlertDialogDescription: "p",
21 AlertDialogFooter: "footer",
22 AlertDialogHeader: "header",
23 AlertDialogPopup: "section",
24 AlertDialogTitle: "h2",
25}));
26vi.mock("./ui/button", () => ({ Button: "button" }));
27vi.mock("./ui/input", () => ({ Input: "input" }));
28vi.mock("./ui/label", () => ({ Label: "label" }));
29vi.mock("./ui/popover", () => ({
30 Popover: ({ children }: { children: ReactNode }) => children,
31 PopoverPopup: () => null,
32 PopoverTrigger: "button",
33}));
34vi.mock("./ui/switch", () => ({ Switch: "input" }));
35vi.mock("./ui/textarea", () => ({ Textarea: "textarea" }));
36
37import {
38 EMPTY_PROJECT_SCRIPT_INPUT,
39 ProjectScriptEditorDialog,
40 type ProjectScriptActionResult,
41 type ProjectScriptEditorRequest,
42} from "./projectScriptEditor";
43
44const onSubmit = vi.fn<Parameters<typeof ProjectScriptEditorDialog>[0]["onSubmit"]>();
45const onClose = vi.fn();
46const onDelete = vi.fn();
47let renderer: ReactTestRenderer | null;
48
49function request(name: string, error?: string): ProjectScriptEditorRequest {
50 return {
51 scriptId: name,
52 initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` },
53 ...(error === undefined ? {} : { error }),
54 };
55}
56
57function editor(nextRequest: ProjectScriptEditorRequest) {
58 return (
59 <StrictMode>
60 <ProjectScriptEditorDialog
61 request={nextRequest}
62 scripts={[]}
63 onSubmit={onSubmit}
64 onClose={onClose}
65 onDelete={onDelete}
66 />
67 </StrictMode>
68 );
69}
70
71function open(nextRequest: ProjectScriptEditorRequest) {
72 act(() => {
73 if (renderer) renderer.update(editor(nextRequest));
74 else renderer = create(editor(nextRequest));
75 });
76}
77
78function submit(): Promise<void> {
79 return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} });
80}
81
82function saveButton() {
83 return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!;
84}
85
86function deferredSave() {
87 let resolve!: (result: ProjectScriptActionResult) => void;
88 let reject!: (error: Error) => void;
89 const promise = new Promise<ProjectScriptActionResult>((resolveResult, rejectResult) => {
90 resolve = resolveResult;
91 reject = rejectResult;
92 });
93 return { promise, resolve, reject };
94}
95
96beforeEach(() => {
97 renderer = null;
98 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
99 onSubmit.mockReset();
100 onClose.mockReset();
101 onDelete.mockReset();
102});
103
104afterEach(async () => {
105 await act(async () => renderer?.unmount());
106 vi.unstubAllGlobals();
107});
108
109describe("project action editor save lifecycle", () => {
110 it("blocks repeated submits and edits until the current save completes", async () => {
111 const save = deferredSave();
112 onSubmit.mockReturnValue(save.promise);
113 open(request("build"));
114
115 let completion!: Promise<void>;
116 act(() => {
117 completion = submit();
118 void submit();
119 });
120
121 expect(onSubmit).toHaveBeenCalledTimes(1);
122 expect(saveButton().props.disabled).toBe(true);
123 expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true);
124 const cancel = renderer!.root
125 .findAllByType("button")
126 .find((button) => button.children.includes("Cancel"))!;
127 expect(cancel.props.disabled).not.toBe(true);
128
129 await act(async () => {
130 save.resolve(AsyncResult.success(undefined));
131 await completion;
132 });
133 expect(onClose).toHaveBeenCalledTimes(1);
134 });
135
136 it("does not close a replacement request or release its in-flight save", async () => {
137 const first = deferredSave();
138 const second = deferredSave();
139 onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
140 open(request("build"));
141 let firstCompletion!: Promise<void>;
142 act(() => {
143 firstCompletion = submit();
144 });
145
146 open(request("test"));
147 expect(saveButton().props.disabled).toBe(false);
148 expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test");
149 let secondCompletion!: Promise<void>;
150 act(() => {
151 secondCompletion = submit();
152 });
153
154 await act(async () => {
155 first.resolve(AsyncResult.success(undefined));
156 await firstCompletion;
157 });
158 expect(onClose).not.toHaveBeenCalled();
159 expect(saveButton().props.disabled).toBe(true);
160
161 await act(async () => {
162 second.resolve(AsyncResult.success(undefined));
163 await secondCompletion;
164 });
165 expect(onClose).toHaveBeenCalledTimes(1);
166 expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]);
167 });
168
169 it.each(["failure", "rejection"] as const)(
170 "ignores a stale %s after the request changes",
171 async (outcome) => {
172 const save = deferredSave();
173 onSubmit.mockReturnValue(save.promise);
174 open(request("build"));
175 let completion!: Promise<void>;
176 act(() => {
177 completion = submit();
178 });
179
180 open(request("test", "New request error"));
181 await act(async () => {
182 if (outcome === "failure")
183 save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error"))));
184 else save.reject(new Error("Old save error"));
185 await completion;
186 });
187
188 const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
189 expect(messages).toContain("New request error");
190 expect(messages).not.toContain("Old save error");
191 expect(saveButton().props.disabled).toBe(false);
192 expect(onClose).not.toHaveBeenCalled();
193 },
194 );
195
196 it("shows a current save error and allows retry", async () => {
197 onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed"))));
198 onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined));
199 open(request("build"));
200
201 await act(async () => {
202 await submit();
203 });
204 expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain(
205 "Save failed",
206 );
207 expect(saveButton().props.disabled).toBe(false);
208 expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false);
209 expect(onClose).not.toHaveBeenCalled();
210
211 await act(async () => {
212 await submit();
213 });
214 expect(onSubmit).toHaveBeenCalledTimes(2);
215 expect(onClose).toHaveBeenCalledTimes(1);
216 });
217
218 it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => {
219 const save = deferredSave();
220 onSubmit.mockReturnValue(save.promise);
221 open(request("build"));
222 let completion!: Promise<void>;
223 act(() => {
224 completion = submit();
225 });
226
227 act(() => {
228 if (exit === "cancel") {
229 renderer!.root
230 .findAllByType("button")
231 .find((button) => button.children.includes("Cancel"))!
232 .props.onClick();
233 } else {
234 renderer!.unmount();
235 renderer = null;
236 }
237 });
238 onClose.mockClear();
239 await act(async () => {
240 save.resolve(AsyncResult.success(undefined));
241 await completion;
242 });
243 expect(onClose).not.toHaveBeenCalled();
244 });
245});
apps/web/src/components/projectScriptEditor.tsx+165-118
...@@ -16,7 +16,14 @@ import {...@@ -16,7 +16,14 @@ import {
16 PlayIcon,16 PlayIcon,
17 WrenchIcon,17 WrenchIcon,
18} from "lucide-react";18} from "lucide-react";
19import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react";19import React, {
20 type FormEvent,
21 type KeyboardEvent,
22 useEffect,
23 useLayoutEffect,
24 useRef,
25 useState,
26} from "react";
2027
21import {28import {
22 keybindingValueForCommand,29 keybindingValueForCommand,
...@@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({...@@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({
156 const [autoOpenPreview, setAutoOpenPreview] = useState(false);163 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
157 const [validationError, setValidationError] = useState<string | null>(null);164 const [validationError, setValidationError] = useState<string | null>(null);
158 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);165 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
166 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);
167 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
159168
160 const isOpen = request !== null;169 const isOpen = request !== null;
161 const isEditing = request?.scriptId != null;170 const isEditing = request?.scriptId != null;
171 const isSaving = request !== null && savingRequest === request;
172
173 // A save completion must not affect a replacement request or an unmounted editor.
174 useLayoutEffect(
175 () => () => {
176 if (pendingSubmissionRef.current?.request === request) {
177 pendingSubmissionRef.current = null;
178 }
179 },
180 [request],
181 );
162182
163 // Hydrate the form whenever a new request opens the dialog.183 // Hydrate the form whenever a new request opens the dialog.
164 useEffect(() => {184 useEffect(() => {
...@@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({...@@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({
172 setPreviewUrl(request.initial.previewUrl ?? "");192 setPreviewUrl(request.initial.previewUrl ?? "");
173 setAutoOpenPreview(request.initial.autoOpenPreview);193 setAutoOpenPreview(request.initial.autoOpenPreview);
174 setValidationError(request.error ?? null);194 setValidationError(request.error ?? null);
195 setSavingRequest(null);
175 }, [request]);196 }, [request]);
176197
198 const close = () => {
199 pendingSubmissionRef.current = null;
200 setSavingRequest(null);
201 setIconPickerOpen(false);
202 onClose();
203 };
204
177 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {205 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
178 if (event.key === "Tab") return;206 if (event.key === "Tab") return;
179 event.preventDefault();207 event.preventDefault();
...@@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({...@@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({
188216
189 const submit = async (event: FormEvent) => {217 const submit = async (event: FormEvent) => {
190 event.preventDefault();218 event.preventDefault();
191 if (!request) return;219 if (!request || pendingSubmissionRef.current !== null) return;
192 const trimmedName = name.trim();220 const trimmedName = name.trim();
193 const trimmedCommand = command.trim();221 const trimmedCommand = command.trim();
194 if (trimmedName.length === 0) {222 if (trimmedName.length === 0) {
...@@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({...@@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({
228 return;256 return;
229 }257 }
230258
231 const result = await onSubmit(request.scriptId, payload);259 const submission = { request };
232 if (result._tag === "Failure") {260 pendingSubmissionRef.current = submission;
233 if (!isAtomCommandInterrupted(result)) {261 setSavingRequest(request);
234 const error = squashAtomCommandFailure(result);262 setIconPickerOpen(false);
263 try {
264 const result = await onSubmit(request.scriptId, payload);
265 if (pendingSubmissionRef.current === submission) {
266 if (result._tag === "Failure") {
267 if (!isAtomCommandInterrupted(result)) {
268 const error = squashAtomCommandFailure(result);
269 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
270 }
271 } else {
272 close();
273 }
274 }
275 } catch (error) {
276 if (pendingSubmissionRef.current === submission) {
235 setValidationError(error instanceof Error ? error.message : "Failed to save action.");277 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
236 }278 }
237 return;
238 }279 }
239 setIconPickerOpen(false);280 if (pendingSubmissionRef.current === submission) {
240 onClose();281 pendingSubmissionRef.current = null;
282 setSavingRequest(null);
283 }
241 };284 };
242285
243 return (286 return (
...@@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({...@@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({
246 open={isOpen}289 open={isOpen}
247 onOpenChange={(open) => {290 onOpenChange={(open) => {
248 if (!open) {291 if (!open) {
249 setIconPickerOpen(false);292 close();
250 onClose();
251 }293 }
252 }}294 }}
253 >295 >
...@@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({...@@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({
259 </DialogDescription>301 </DialogDescription>
260 </DialogHeader>302 </DialogHeader>
261 <DialogPanel>303 <DialogPanel>
262 <form id={formId} className="space-y-4" onSubmit={submit}>304 <form id={formId} onSubmit={submit}>
263 <div className="space-y-1.5">305 <fieldset className="space-y-4" disabled={isSaving}>
264 <Label htmlFor="script-name">Name</Label>306 <div className="space-y-1.5">
265 <div className="flex items-center gap-2">307 <Label htmlFor="script-name">Name</Label>
266 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>308 <div className="flex items-center gap-2">
267 <PopoverTrigger309 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
268 render={310 <PopoverTrigger
269 <Button311 render={
270 type="button"312 <Button
271 variant="outline"313 type="button"
272 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"314 variant="outline"
273 aria-label="Choose icon"315 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none"
274 />316 aria-label="Choose icon"
275 }317 />
276 >318 }
277 <ScriptIcon icon={icon} className="size-4.5" />319 >
278 </PopoverTrigger>320 <ScriptIcon icon={icon} className="size-4.5" />
279 <PopoverPopup align="start">321 </PopoverTrigger>
280 <div className="grid grid-cols-3 gap-2">322 <PopoverPopup align="start">
281 {SCRIPT_ICONS.map((entry) => {323 <div className="grid grid-cols-3 gap-2">
282 const isSelected = entry.id === icon;324 {SCRIPT_ICONS.map((entry) => {
283 return (325 const isSelected = entry.id === icon;
284 <button326 return (
285 key={entry.id}327 <button
286 type="button"328 key={entry.id}
287 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${329 type="button"
288 isSelected330 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
289 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"331 isSelected
290 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"332 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
291 }`}333 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
292 onClick={() => {334 }`}
293 setIcon(entry.id);335 onClick={() => {
294 setIconPickerOpen(false);336 setIcon(entry.id);
295 }}337 setIconPickerOpen(false);
296 >338 }}
297 <ScriptIcon icon={entry.id} className="size-4" />339 >
298 <span>{entry.label}</span>340 <ScriptIcon icon={entry.id} className="size-4" />
299 </button>341 <span>{entry.label}</span>
300 );342 </button>
301 })}343 );
302 </div>344 })}
303 </PopoverPopup>345 </div>
304 </Popover>346 </PopoverPopup>
347 </Popover>
348 <Input
349 id="script-name"
350 autoFocus
351 placeholder="Test"
352 value={name}
353 onChange={(event) => setName(event.target.value)}
354 />
355 </div>
356 </div>
357 <div className="space-y-1.5">
358 <Label htmlFor="script-keybinding">Keybinding</Label>
305 <Input359 <Input
306 id="script-name"360 id="script-keybinding"
307 autoFocus361 placeholder="Press shortcut"
308 placeholder="Test"362 value={keybinding}
309 value={name}363 readOnly
310 onChange={(event) => setName(event.target.value)}364 onKeyDown={captureKeybinding}
311 />365 />
366 <p className="text-xs text-muted-foreground">
367 Press a shortcut. Use <code>Backspace</code> to clear. Shortcuts are
368 environment-wide. Projects using the same action share its shortcut.
369 </p>
312 </div>370 </div>
313 </div>371 <div className="space-y-1.5">
314 <div className="space-y-1.5">372 <Label htmlFor="script-command">Command</Label>
315 <Label htmlFor="script-keybinding">Keybinding</Label>373 <Textarea
316 <Input374 id="script-command"
317 id="script-keybinding"375 placeholder="bun test"
318 placeholder="Press shortcut"376 value={command}
319 value={keybinding}377 onChange={(event) => setCommand(event.target.value)}
320 readOnly378 />
321 onKeyDown={captureKeybinding}379 </div>
322 />380 <div className="space-y-1.5">
323 <p className="text-xs text-muted-foreground">381 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
324 Press a shortcut. Use <code>Backspace</code> to clear.382 <Input
325 </p>383 id="script-preview-url"
326 </div>384 placeholder="http://localhost:5173"
327 <div className="space-y-1.5">385 value={previewUrl}
328 <Label htmlFor="script-command">Command</Label>386 onChange={(event) => setPreviewUrl(event.target.value)}
329 <Textarea387 />
330 id="script-command"388 <p className="text-xs text-muted-foreground">
331 placeholder="bun test"389 Open this URL in the in-app preview when this action runs.
332 value={command}390 </p>
333 onChange={(event) => setCommand(event.target.value)}391 </div>
334 />392 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
335 </div>393 <span>Run automatically on worktree creation</span>
336 <div className="space-y-1.5">394 <Switch
337 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>395 checked={runOnWorktreeCreate}
338 <Input396 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
339 id="script-preview-url"397 />
340 placeholder="http://localhost:5173"398 </label>
341 value={previewUrl}399 <label
342 onChange={(event) => setPreviewUrl(event.target.value)}400 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
343 />401 previewUrl.trim().length === 0 ? "opacity-60" : ""
344 <p className="text-xs text-muted-foreground">402 }`}
345 Open this URL in the in-app preview when this action runs.403 >
346 </p>404 <span>Open preview automatically when this action runs</span>
347 </div>405 <Switch
348 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">406 checked={autoOpenPreview}
349 <span>Run automatically on worktree creation</span>407 disabled={previewUrl.trim().length === 0}
350 <Switch408 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
351 checked={runOnWorktreeCreate}409 />
352 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}410 </label>
353 />411 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
354 </label>412 </fieldset>
355 <label
356 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
357 previewUrl.trim().length === 0 ? "opacity-60" : ""
358 }`}
359 >
360 <span>Open preview automatically when this action runs</span>
361 <Switch
362 checked={autoOpenPreview}
363 disabled={previewUrl.trim().length === 0}
364 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
365 />
366 </label>
367 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
368 </form>413 </form>
369 </DialogPanel>414 </DialogPanel>
370 <DialogFooter className="dark:border-transparent dark:bg-transparent">415 <DialogFooter className="dark:border-transparent dark:bg-transparent">
...@@ -373,16 +418,17 @@ export function ProjectScriptEditorDialog({...@@ -373,16 +418,17 @@ export function ProjectScriptEditorDialog({
373 type="button"418 type="button"
374 variant="destructive-outline"419 variant="destructive-outline"
375 className="mr-auto"420 className="mr-auto"
421 disabled={isSaving}
376 onClick={() => setDeleteConfirmOpen(true)}422 onClick={() => setDeleteConfirmOpen(true)}
377 >423 >
378 Delete424 Delete
379 </Button>425 </Button>
380 )}426 )}
381 <Button type="button" variant="outline" onClick={onClose}>427 <Button type="button" variant="outline" onClick={close}>
382 Cancel428 Cancel
383 </Button>429 </Button>
384 <Button form={formId} type="submit">430 <Button form={formId} type="submit" disabled={isSaving}>
385 {isEditing ? "Save changes" : "Save action"}431 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}
386 </Button>432 </Button>
387 </DialogFooter>433 </DialogFooter>
388 </DialogPopup>434 </DialogPopup>
...@@ -398,10 +444,11 @@ export function ProjectScriptEditorDialog({...@@ -398,10 +444,11 @@ export function ProjectScriptEditorDialog({
398 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>444 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
399 <Button445 <Button
400 variant="destructive"446 variant="destructive"
447 disabled={isSaving}
401 onClick={() => {448 onClick={() => {
402 if (!request?.scriptId) return;449 if (!request?.scriptId) return;
403 setDeleteConfirmOpen(false);450 setDeleteConfirmOpen(false);
404 onClose();451 close();
405 onDelete(request.scriptId);452 onDelete(request.scriptId);
406 }}453 }}
407 >454 >
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+20-7
...@@ -5,6 +5,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro...@@ -5,6 +5,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro
5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6import {6import {
7 type EnvironmentId,7 type EnvironmentId,
8 DEFAULT_SERVER_SETTINGS,
8 type PullRequestAction,9 type PullRequestAction,
9 type PullRequestMergeMethod,10 type PullRequestMergeMethod,
10 type PullRequestListEntry,11 type PullRequestListEntry,
...@@ -13,6 +14,7 @@ import {...@@ -13,6 +14,7 @@ import {
13 resolveEnvironmentMachineKind,14 resolveEnvironmentMachineKind,
14 type ScopedThreadRef,15 type ScopedThreadRef,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
16import {18import {
17 ArrowDownUpIcon,19 ArrowDownUpIcon,
18 ArrowLeftIcon,20 ArrowLeftIcon,
...@@ -55,8 +57,8 @@ import {...@@ -55,8 +57,8 @@ import {
5557
56import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";58import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
57import { useNewThreadHandler } from "~/hooks/useHandleNewThread";59import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
58import { useClientSettings } from "~/hooks/useSettings";
59import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";60import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
61import { useClientSettings } from "~/hooks/useSettings";
60import {62import {
61 deriveLogicalProjectKeyFromSettings,63 deriveLogicalProjectKeyFromSettings,
62 derivePhysicalProjectKey,64 derivePhysicalProjectKey,
...@@ -585,10 +587,19 @@ export function PullRequestDetailPanel({...@@ -585,10 +587,19 @@ export function PullRequestDetailPanel({
585 }, [condensed]);587 }, [condensed]);
586 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);588 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
587 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);589 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
588 const mergeMethodOverrides = useClientSettings(590 // Server-side and per project, like every other project setting. The
591 // client-local per-project map from before still answers when the server
592 // has no value, so a choice made on an older release keeps applying until
593 // it is set (or reset) in Settings.
594 const legacyMergeMethodOverrides = useClientSettings(
589 (settings) => settings.pullRequestMergeMethodOverrides,595 (settings) => settings.pullRequestMergeMethodOverrides,
590 );596 );
591 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);597 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
598 const projectDefaultMergeMethod =
599 resolveProjectSettings(
600 environmentConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS,
601 reference.projectId,
602 ).settings.pullRequestMergeMethod ?? undefined;
592 const [mergeMethodSelection, setMergeMethodSelection] = useState<{603 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
593 readonly pullRequestKey: string;604 readonly pullRequestKey: string;
594 readonly method: PullRequestMergeMethod;605 readonly method: PullRequestMergeMethod;
...@@ -834,9 +845,10 @@ export function PullRequestDetailPanel({...@@ -834,9 +845,10 @@ export function PullRequestDetailPanel({
834 )?.repositoryIdentity;845 )?.repositoryIdentity;
835 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);846 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
836 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);847 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
837 // Project settings store the override under the sidebar group's key, which a duplicate row848 // Project settings stored the override under the sidebar group's key, which a duplicate row
838 // borrows from its siblings, so the project alone does not always name the same key.849 // borrows from its siblings, so the project alone does not always name the same key.
839 const projectDefaultMergeMethod = useMemo(() => {850 const legacyProjectDefaultMergeMethod = useMemo(() => {
851 if (projectDefaultMergeMethod !== undefined) return undefined;
840 const project = projects.find(852 const project = projects.find(
841 (candidate) =>853 (candidate) =>
842 candidate.environmentId === environmentId && candidate.id === reference.projectId,854 candidate.environmentId === environmentId && candidate.id === reference.projectId,
...@@ -849,11 +861,12 @@ export function PullRequestDetailPanel({...@@ -849,11 +861,12 @@ export function PullRequestDetailPanel({
849 primaryEnvironmentId,861 primaryEnvironmentId,
850 }).get(derivePhysicalProjectKey(project)) ??862 }).get(derivePhysicalProjectKey(project)) ??
851 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);863 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
852 return mergeMethodOverrides[projectKey];864 return legacyMergeMethodOverrides[projectKey];
853 }, [865 }, [
854 environmentId,866 environmentId,
855 mergeMethodOverrides,867 legacyMergeMethodOverrides,
856 primaryEnvironmentId,868 primaryEnvironmentId,
869 projectDefaultMergeMethod,
857 projectGroupingSettings,870 projectGroupingSettings,
858 projects,871 projects,
859 reference.projectId,872 reference.projectId,
...@@ -1327,7 +1340,7 @@ export function PullRequestDetailPanel({...@@ -1327,7 +1340,7 @@ export function PullRequestDetailPanel({
1327 const selectedMergeMethod = resolvePullRequestMergeMethod(1340 const selectedMergeMethod = resolvePullRequestMergeMethod(
1328 allowedMergeMethods,1341 allowedMergeMethods,
1329 currentMergeMethod,1342 currentMergeMethod,
1330 projectDefaultMergeMethod,1343 projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod,
1331 lastSelectedMergeMethod,1344 lastSelectedMergeMethod,
1332 );1345 );
1333 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];1346 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1
...@@ -3133,7 +3133,10 @@ export function ConnectionsSettings() {...@@ -3133,7 +3133,10 @@ export function ConnectionsSettings() {
3133 <SettingsPageContainer>3133 <SettingsPageContainer>
3134 {canManageLocalBackend ? (3134 {canManageLocalBackend ? (
3135 <>3135 <>
3136 <SettingsSection {...searchableSetting("connections-environment")}>3136 <SettingsSection
3137 {...searchableSetting("connections-environment")}
3138 title={primaryEnvironment?.label ?? "Primary environment"}
3139 >
3137 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (3140 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
3138 <SettingsRow3141 <SettingsRow
3139 title={3142 title={
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-20
...@@ -7,12 +7,11 @@ import {...@@ -7,12 +7,11 @@ import {
7 FolderOpenIcon,7 FolderOpenIcon,
8 InfoIcon,8 InfoIcon,
9} from "lucide-react";9} from "lucide-react";
10import { useAtomValue } from "@effect/atom-react";
11import {10import {
12 isAtomCommandInterrupted,11 isAtomCommandInterrupted,
13 squashAtomCommandFailure,12 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";13} from "@t3tools/client-runtime/state/runtime";
15import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";14import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
16import type {15import type {
17 ServerProcessDiagnosticsEntry,16 ServerProcessDiagnosticsEntry,
18 ServerProcessResourceHistorySummary,17 ServerProcessResourceHistorySummary,
...@@ -26,13 +25,8 @@ import { ensureLocalApi } from "../../localApi";...@@ -26,13 +25,8 @@ import { ensureLocalApi } from "../../localApi";
26import { resolveAndPersistPreferredEditor } from "../../editorPreferences";25import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
27import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";26import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
28import { useEnvironmentQuery } from "../../state/query";27import { useEnvironmentQuery } from "../../state/query";
29import {28import { serverEnvironment } from "../../state/server";
30 primaryServerAvailableEditorsAtom,
31 primaryServerObservabilityAtom,
32 serverEnvironment,
33} from "../../state/server";
34import { shellEnvironment } from "../../state/shell";29import { shellEnvironment } from "../../state/shell";
35import { usePrimaryEnvironment } from "../../state/environments";
36import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";30import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
37import { Button } from "../ui/button";31import { Button } from "../ui/button";
38import { ScrollArea } from "../ui/scroll-area";32import { ScrollArea } from "../ui/scroll-area";
...@@ -43,6 +37,7 @@ import { ExpandableText } from "./ExpandableText";...@@ -43,6 +37,7 @@ import { ExpandableText } from "./ExpandableText";
43import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";37import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
44import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";38import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
45import { useAtomCommand } from "../../state/use-atom-command";39import { useAtomCommand } from "../../state/use-atom-command";
40import { useSettingsScope } from "./SettingsScopeContext";
4641
47const NUMBER_FORMAT = new Intl.NumberFormat();42const NUMBER_FORMAT = new Intl.NumberFormat();
4843
...@@ -776,10 +771,12 @@ function DiagnosticsRefreshButton({...@@ -776,10 +771,12 @@ function DiagnosticsRefreshButton({
776}771}
777772
778export function DiagnosticsSettingsPanel() {773export function DiagnosticsSettingsPanel() {
779 const observability = useAtomValue(primaryServerObservabilityAtom);774 const { environment } = useSettingsScope();
780 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);775 // The boundary only mounts this page when the selection resolves to one
781 const primaryEnvironment = usePrimaryEnvironment();776 // connected environment, so the representative is the one to inspect.
782 const environmentId = primaryEnvironment?.environmentId ?? null;777 const environmentId = environment?.environmentId ?? null;
778 const observability = environment?.serverConfig?.observability;
779 const availableEditors = environment?.serverConfig?.availableEditors;
783 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {780 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
784 reportFailure: false,781 reportFailure: false,
785 });782 });
...@@ -827,8 +824,15 @@ export function DiagnosticsSettingsPanel() {...@@ -827,8 +824,15 @@ export function DiagnosticsSettingsPanel() {
827 const signalingPidRef = useRef<number | null>(null);824 const signalingPidRef = useRef<number | null>(null);
828 const environmentIdRef = useRef(environmentId);825 const environmentIdRef = useRef(environmentId);
829 const processDataRef = useRef(processData);826 const processDataRef = useRef(processData);
830 environmentIdRef.current = environmentId;827 useEffect(() => {
831 processDataRef.current = processData;828 processDataRef.current = processData;
829 }, [processData]);
830 useEffect(() => {
831 environmentIdRef.current = environmentId;
832 return () => {
833 environmentIdRef.current = null;
834 };
835 }, [environmentId]);
832836
833 const openLogsDirectory = useCallback(() => {837 const openLogsDirectory = useCallback(() => {
834 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;838 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
...@@ -868,6 +872,9 @@ export function DiagnosticsSettingsPanel() {...@@ -868,6 +872,9 @@ export function DiagnosticsSettingsPanel() {
868 const isProcessInitialLoading = isProcessPending && processData === null;872 const isProcessInitialLoading = isProcessPending && processData === null;
869 const signalProcess = useCallback(873 const signalProcess = useCallback(
870 async (pid: number, signal: ServerProcessSignal) => {874 async (pid: number, signal: ServerProcessSignal) => {
875 const targetEnvironmentId = environmentIdRef.current;
876 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);
877 if (targetEnvironmentId === null || process === undefined) return;
871 if (signalingPidRef.current !== null) return;878 if (signalingPidRef.current !== null) return;
872 signalingPidRef.current = pid;879 signalingPidRef.current = pid;
873 setSignalingPid(pid);880 setSignalingPid(pid);
...@@ -896,20 +903,21 @@ export function DiagnosticsSettingsPanel() {...@@ -896,20 +903,21 @@ export function DiagnosticsSettingsPanel() {
896 return;903 return;
897 }904 }
898 }905 }
899 const currentEnvironmentId = environmentIdRef.current;906 if (environmentIdRef.current !== targetEnvironmentId) {
900 if (currentEnvironmentId === null) {
901 clearSignaling();907 clearSignaling();
902 return;908 return;
903 }909 }
904 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);910 if (
905 if (process === undefined) {911 processDataRef.current?.processes.find((entry) => entry.pid === pid)?.startTimeMs !==
912 process.startTimeMs
913 ) {
906 clearSignaling();914 clearSignaling();
907 return;915 return;
908 }916 }
909917
910 try {918 try {
911 const result = await signalServerProcess({919 const result = await signalServerProcess({
912 environmentId: currentEnvironmentId,920 environmentId: targetEnvironmentId,
913 input: { pid, startTimeMs: process.startTimeMs, signal },921 input: { pid, startTimeMs: process.startTimeMs, signal },
914 });922 });
915 if (result._tag === "Failure") {923 if (result._tag === "Failure") {
...@@ -960,7 +968,7 @@ export function DiagnosticsSettingsPanel() {...@@ -960,7 +968,7 @@ export function DiagnosticsSettingsPanel() {
960968
961 return (969 return (
962 <SettingsPageContainer width="expanded" className="gap-10">970 <SettingsPageContainer width="expanded" className="gap-10">
963 <ResourceTelemetryDiagnostics />971 <ResourceTelemetryDiagnostics environmentId={environmentId} />
964972
965 <SettingsSection973 <SettingsSection
966 title="Live Processes"974 title="Live Processes"
apps/web/src/components/settings/IntegrationsSettings.test.tsx+12
...@@ -38,6 +38,18 @@ vi.mock("./settingsLayout", async (importOriginal) => ({...@@ -38,6 +38,18 @@ vi.mock("./settingsLayout", async (importOriginal) => ({
38 ...(await importOriginal<typeof import("./settingsLayout")>()),38 ...(await importOriginal<typeof import("./settingsLayout")>()),
39 SettingsPageContainer: ({ children }: { children: ReactNode }) => children,39 SettingsPageContainer: ({ children }: { children: ReactNode }) => children,
40}));40}));
41// The scoped agent-access rows need the settings layout's scope provider;
42// this test covers the device-local browser sections only.
43vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null }));
44vi.mock("./SettingsScopeContext", () => ({
45 useSettingsScope: () => ({
46 scope: { kind: "all", environmentIds: [] },
47 environment: null,
48 connectedEnvironments: [],
49 targets: [],
50 }),
51 useOptionalSettingsScope: () => null,
52}));
4153
42import { IntegrationsSettingsPanel } from "./IntegrationsSettings";54import { IntegrationsSettingsPanel } from "./IntegrationsSettings";
43import { platformSetupStatus } from "../device/DeviceSetup";55import { platformSetupStatus } from "../device/DeviceSetup";
apps/web/src/components/settings/IntegrationsSettings.tsx+16-59
...@@ -36,7 +36,6 @@ import {...@@ -36,7 +36,6 @@ import {
36 type PreviewViewportSetting,36 type PreviewViewportSetting,
37} from "@t3tools/contracts";37} from "@t3tools/contracts";
38import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";38import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
39import { Link } from "@tanstack/react-router";
40import { MoreVertical, Plus as PlusIcon } from "lucide-react";39import { MoreVertical, Plus as PlusIcon } from "lucide-react";
41import { useCallback, useRef, useState } from "react";40import { useCallback, useRef, useState } from "react";
4241
...@@ -110,6 +109,8 @@ import {...@@ -110,6 +109,8 @@ import {
110 SettingsSection,109 SettingsSection,
111} from "./settingsLayout";110} from "./settingsLayout";
112import { searchableSetting } from "./settingsSearch";111import { searchableSetting } from "./settingsSearch";
112import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
113import { useSettingsScope } from "./SettingsScopeContext";
113import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";114import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";
114import type { ImportOutcome } from "./browserImportWizard.logic";115import type { ImportOutcome } from "./browserImportWizard.logic";
115116
...@@ -564,65 +565,22 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })...@@ -564,65 +565,22 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
564 );565 );
565}566}
566567
567function AgentBrowserAccessSetting() {568/**
568 return (569 * Device support installs helper processes and hosts on one machine, so it
569 <SettingsRow570 * follows the environment crumb. With several environments selected it shows
570 {...searchableSetting("agent-browser-access")}571 * the representative, named in the section title.
571 description="Choose whether agents can use the preview browser for all projects or a specific project."572 */
572 control={
573 <Button
574 render={
575 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
576 }
577 size="sm"
578 variant="outline"
579 >
580 Project settings
581 </Button>
582 }
583 />
584 );
585}
586
587function DeviceIntegrationSettings() {573function DeviceIntegrationSettings() {
588 const primaryEnvironment = usePrimaryEnvironment();574 const { scope, environment: selected, connectedEnvironments } = useSettingsScope();
589 const { environments } = useEnvironments();
590 const [selectedId, setSelectedId] = useState<string | null>(null);
591 const selected =
592 environments.find((environment) => environment.environmentId === selectedId) ??
593 environments.find(
594 (environment) => environment.environmentId === primaryEnvironment?.environmentId,
595 ) ??
596 environments[0];
597 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;575 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
598 const environmentId = connected ? selected.environmentId : null;576 const environmentId = connected ? selected.environmentId : null;
577 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
599578
600 return (579 return (
601 <SettingsSection id="devices" title="Devices">580 <SettingsSection
602 {environments.length > 1 ? (581 id="devices"
603 <SettingsRow582 title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"}
604 title="Environment"583 >
605 description="Device support and hosts are shared by all projects in this environment."
606 control={
607 <Select
608 value={selected?.environmentId ?? ""}
609 onValueChange={(value) => setSelectedId(value)}
610 >
611 <SelectTrigger size="sm" aria-label="Device environment">
612 <SelectValue>{selected?.label ?? "Select environment"}</SelectValue>
613 </SelectTrigger>
614 <SelectPopup align="end" alignItemWithTrigger={false}>
615 {environments.map((environment) => (
616 <SelectItem key={environment.environmentId} value={environment.environmentId}>
617 {environment.label}
618 {environment.connection.phase === "connected" ? "" : " · Offline"}
619 </SelectItem>
620 ))}
621 </SelectPopup>
622 </Select>
623 }
624 />
625 ) : null}
626 <DeviceIntegrationControls584 <DeviceIntegrationControls
627 key={selected?.environmentId ?? "none"}585 key={selected?.environmentId ?? "none"}
628 environmentId={environmentId}586 environmentId={environmentId}
...@@ -1339,11 +1297,10 @@ export function IntegrationsSettingsPanel() {...@@ -1339,11 +1297,10 @@ export function IntegrationsSettingsPanel() {
13391297
1340 return (1298 return (
1341 <SettingsPageContainer>1299 <SettingsPageContainer>
1300 {/* Server-authoritative agent access is scoped by the header selection;
1301 the preview defaults below are device-local and ignore it. */}
1302 <ProjectDefaultsSettings category="integrations" />
1342 <SettingsSection id="browser" title="Browser">1303 <SettingsSection id="browser" title="Browser">
1343 {/* Server-authoritative, so it stays editable on any client anchored to
1344 a server; `serverScoped` covers the hosted app, which has none. It
1345 sits outside the block covering the desktop-only defaults. */}
1346 <AgentBrowserAccessSetting />
1347 {previewDefaultsDisabled ? (1304 {previewDefaultsDisabled ? (
1348 <SettingsUnavailableGroup message="Only available in the desktop app.">1305 <SettingsUnavailableGroup message="Only available in the desktop app.">
1349 {previewDefaults}1306 {previewDefaults}
apps/web/src/components/settings/KeybindingsSettings.tsx+31-26
...@@ -26,7 +26,7 @@ import {...@@ -26,7 +26,7 @@ import {
26 type ServerRemoveKeybindingInput,26 type ServerRemoveKeybindingInput,
27 type ServerUpsertKeybindingInput,27 type ServerUpsertKeybindingInput,
28} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import { useAtomValue } from "@effect/atom-react";29import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
30import {30import {
31 isAtomCommandInterrupted,31 isAtomCommandInterrupted,
32 squashAtomCommandFailure,32 squashAtomCommandFailure,
...@@ -36,13 +36,8 @@ import { isElectron } from "../../env";...@@ -36,13 +36,8 @@ import { isElectron } from "../../env";
36import { useOpenInPreferredEditor } from "../../editorPreferences";36import { useOpenInPreferredEditor } from "../../editorPreferences";
37import { formatShortcutLabel } from "../../keybindings";37import { formatShortcutLabel } from "../../keybindings";
38import { cn } from "../../lib/utils";38import { cn } from "../../lib/utils";
39import {39import { serverEnvironment } from "../../state/server";
40 primaryServerAvailableEditorsAtom,40import { useSettingsScope } from "./SettingsScopeContext";
41 primaryServerKeybindingsAtom,
42 primaryServerKeybindingsConfigPathAtom,
43 serverEnvironment,
44} from "../../state/server";
45import { usePrimaryEnvironment } from "../../state/environments";
46import { Badge } from "../ui/badge";41import { Badge } from "../ui/badge";
47import { Button } from "../ui/button";42import { Button } from "../ui/button";
48import { Input } from "../ui/input";43import { Input } from "../ui/input";
...@@ -1335,10 +1330,13 @@ function BrowserKeybindingNotice() {...@@ -1335,10 +1330,13 @@ function BrowserKeybindingNotice() {
1335}1330}
13361331
1337export function KeybindingsSettingsPanel() {1332export function KeybindingsSettingsPanel() {
1338 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1333 // The representative environment supplies the displayed bindings; edits
1339 const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom);1334 // fan out to every connected environment in the selection, so one
1340 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);1335 // shortcut change reaches each machine the user runs T3 Code on.
1341 const primaryEnvironment = usePrimaryEnvironment();1336 const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();
1337 const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
1338 const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;
1339 const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];
1342 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {1340 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
1343 reportFailure: false,1341 reportFailure: false,
1344 });1342 });
...@@ -1411,17 +1409,19 @@ export function KeybindingsSettingsPanel() {...@@ -1411,17 +1409,19 @@ export function KeybindingsSettingsPanel() {
1411 ...(input.replace ? { replace: input.replace } : {}),1409 ...(input.replace ? { replace: input.replace } : {}),
1412 };1410 };
1413 void (async () => {1411 void (async () => {
1414 const result = await upsertKeybinding({1412 const results = await Promise.all(
1415 environmentId: primaryEnvironment.environmentId,1413 connectedEnvironments.map((target) =>
1416 input: payload,1414 upsertKeybinding({ environmentId: target.environmentId, input: payload }),
1417 });1415 ),
1416 );
1418 setSavingCommand(null);1417 setSavingCommand(null);
1419 if (result._tag === "Success") {1418 const failed = results.find((result) => result._tag === "Failure");
1419 if (!failed) {
1420 setIsAddingBinding(false);1420 setIsAddingBinding(false);
1421 return;1421 return;
1422 }1422 }
1423 if (!isAtomCommandInterrupted(result)) {1423 if (!isAtomCommandInterrupted(failed)) {
1424 const error = squashAtomCommandFailure(result);1424 const error = squashAtomCommandFailure(failed);
1425 toastManager.add({1425 toastManager.add({
1426 title: "Unable to save keybinding",1426 title: "Unable to save keybinding",
1427 description: error instanceof Error ? error.message : "The keybinding was not saved.",1427 description: error instanceof Error ? error.message : "The keybinding was not saved.",
...@@ -1430,7 +1430,7 @@ export function KeybindingsSettingsPanel() {...@@ -1430,7 +1430,7 @@ export function KeybindingsSettingsPanel() {
1430 }1430 }
1431 })();1431 })();
1432 },1432 },
1433 [primaryEnvironment, upsertKeybinding],1433 [connectedEnvironments, primaryEnvironment, upsertKeybinding],
1434 );1434 );
14351435
1436 const removeKeybinding = useCallback(1436 const removeKeybinding = useCallback(
...@@ -1438,12 +1438,17 @@ export function KeybindingsSettingsPanel() {...@@ -1438,12 +1438,17 @@ export function KeybindingsSettingsPanel() {
1438 if (!primaryEnvironment) return;1438 if (!primaryEnvironment) return;
1439 setSavingCommand(row.command);1439 setSavingCommand(row.command);
1440 void (async () => {1440 void (async () => {
1441 const result = await removeKeybindingMutation({1441 const results = await Promise.all(
1442 environmentId: primaryEnvironment.environmentId,1442 connectedEnvironments.map((target) =>
1443 input: rowKeybindingTarget(row),1443 removeKeybindingMutation({
1444 });1444 environmentId: target.environmentId,
1445 input: rowKeybindingTarget(row),
1446 }),
1447 ),
1448 );
1445 setSavingCommand(null);1449 setSavingCommand(null);
1446 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {1450 const result = results.find((entry) => entry._tag === "Failure") ?? results[0];
1451 if (result?._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1447 const error = squashAtomCommandFailure(result);1452 const error = squashAtomCommandFailure(result);
1448 toastManager.add({1453 toastManager.add({
1449 title: "Unable to remove keybinding",1454 title: "Unable to remove keybinding",
...@@ -1453,7 +1458,7 @@ export function KeybindingsSettingsPanel() {...@@ -1453,7 +1458,7 @@ export function KeybindingsSettingsPanel() {
1453 }1458 }
1454 })();1459 })();
1455 },1460 },
1456 [primaryEnvironment, removeKeybindingMutation],1461 [connectedEnvironments, primaryEnvironment, removeKeybindingMutation],
1457 );1462 );
14581463
1459 const resetKeybinding = useCallback(1464 const resetKeybinding = useCallback(
apps/web/src/components/settings/ProjectActionsSettings.tsx created+226
...@@ -0,0 +1,226 @@
1import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
7import { ChevronDownIcon, PlusIcon } from "lucide-react";
8import { useCallback, useMemo, useState } from "react";
9import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
10import { useEnvironments } from "../../state/environments";
11import {
12 EMPTY_PROJECT_SCRIPT_INPUT,
13 editorRequestForScript,
14 ProjectScriptEditorDialog,
15 ScriptIcon,
16 type NewProjectScriptInput,
17 type ProjectScriptEditorRequest,
18} from "../projectScriptEditor";
19import { Button } from "../ui/button";
20import {
21 Menu,
22 MenuGroup,
23 MenuGroupLabel,
24 MenuItem,
25 MenuPopup,
26 MenuSeparator,
27 MenuTrigger,
28} from "../ui/menu";
29import { ProjectActionsList } from "./ProjectActionsList";
30import { useProjectScriptSettings } from "./useProjectScriptSettings";
31import { SettingsRow, SettingsSection } from "./settingsLayout";
32import { useSettingsScope } from "./SettingsScopeContext";
33
34/**
35 * A project's actions on each selected environment. Actions belong to a
36 * project, so this only renders at a project scope; the environment's
37 * `defaultProjectScripts` is the layer a project without its own list
38 * inherits. Shortcuts are environment-wide, so the same action id shares its
39 * binding on an environment.
40 */
41export function ProjectActionsSettings() {
42 const { scope, targets, target } = useSettingsScope();
43 const { environments } = useEnvironments();
44 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
45 const representativeConfig = target
46 ? environments.find((environment) => environment.environmentId === target.environmentId)
47 ?.serverConfig
48 : undefined;
49 const scripts = target?.settings.defaultProjectScripts ?? [];
50 const keybindings = representativeConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
51 const mixed = targets.some(
52 (candidate) =>
53 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),
54 );
55 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
56 const memberById = new Map(
57 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],
58 );
59 const { saving, persist, submit } = useProjectScriptSettings(
60 targets.flatMap((candidate) => {
61 const environment = environments.find(
62 (entry) => entry.environmentId === candidate.environmentId,
63 );
64 if (!environment?.serverConfig) return [];
65 const member = candidate.projectId ? memberById.get(candidate.projectId) : undefined;
66 // An older server ignores the override record, so a project edit there
67 // would report success and vanish; such environments are left out and
68 // the legacy per-project map keeps serving them.
69 if (
70 member &&
71 environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
72 ) {
73 return [];
74 }
75 return [
76 {
77 environmentId: candidate.environmentId,
78 // Writes read the raw environment settings so an override entry is
79 // extended, not derived from already-resolved values.
80 settings: environment.serverConfig.settings,
81 keybindings: environment.serverConfig.keybindings,
82 ...(member ? { project: member } : {}),
83 },
84 ];
85 }),
86 );
87
88 // A project's t3.json can declare actions to import. Read it from the
89 // representative checkout; the imported action still fans out.
90 const representativeMember = target?.projectId ? memberById.get(target.projectId) : undefined;
91 const t3File = useT3ProjectFileState(
92 representativeMember?.environmentId ?? EnvironmentId.make("none"),
93 representativeMember?.workspaceRoot ?? null,
94 );
95 const importableScripts = useMemo(
96 () =>
97 t3File.scripts.filter(
98 (fileScript) =>
99 !scripts.some(
100 (script) =>
101 script.command === fileScript.command ||
102 script.name.toLowerCase() === fileScript.name.toLowerCase(),
103 ),
104 ),
105 [scripts, t3File.scripts],
106 );
107 const importFileScript = useCallback(
108 async (fileScript: T3ProjectFileScript) => {
109 const payload: NewProjectScriptInput = {
110 name: fileScript.name,
111 command: fileScript.command,
112 icon: fileScript.icon ?? "play",
113 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
114 keybinding: null,
115 previewUrl: fileScript.previewUrl ?? null,
116 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
117 };
118 const result = await submit(null, payload);
119 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
120 const error = squashAtomCommandFailure(result);
121 setRequest({
122 scriptId: null,
123 initial: payload,
124 error: error instanceof Error ? error.message : "Failed to import action.",
125 });
126 }
127 },
128 [submit],
129 );
130
131 return (
132 <SettingsSection id="project-actions" title="Actions">
133 <SettingsRow
134 serverScoped
135 settingKeys={["defaultProjectScripts"]}
136 mixed={mixed}
137 title="Actions"
138 description="Commands that run in this project's checkout or its worktree, with optional shortcuts."
139 onResetOverride={() => void persist(() => null)}
140 control={
141 <div className="flex flex-wrap items-center gap-1.5">
142 {importableScripts.length > 0 ? (
143 <Menu>
144 <MenuTrigger
145 render={
146 <Button
147 id="import-scripts"
148 size="xs"
149 variant="ghost"
150 disabled={saving}
151 type="button"
152 />
153 }
154 >
155 Import scripts
156 <ChevronDownIcon className="size-3.5" />
157 </MenuTrigger>
158 <MenuPopup align="end" className="w-72">
159 <MenuGroup>
160 <MenuGroupLabel>Import from t3.json</MenuGroupLabel>
161 <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground">
162 Add actions declared by this checkout without editing them first.
163 </p>
164 </MenuGroup>
165 <MenuSeparator />
166 {importableScripts.map((fileScript) => (
167 <MenuItem
168 key={`${fileScript.name} ${fileScript.command}`}
169 onClick={() => void importFileScript(fileScript)}
170 >
171 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" />
172 <div className="min-w-0 flex-1">
173 <div className="truncate font-medium">{fileScript.name}</div>
174 <div className="truncate font-mono text-muted-foreground">
175 {fileScript.command}
176 </div>
177 </div>
178 </MenuItem>
179 ))}
180 </MenuPopup>
181 </Menu>
182 ) : null}
183 <Button
184 size="xs"
185 variant="outline"
186 disabled={saving || targets.length === 0}
187 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
188 >
189 <PlusIcon className="size-3.5" />
190 Add action
191 </Button>
192 </div>
193 }
194 />
195 {mixed ? (
196 <SettingsRow
197 title="Different actions across environments"
198 description="Choose one environment to edit its list. Adding an action here adds it on every selected environment."
199 />
200 ) : (
201 <ProjectActionsList
202 scripts={scripts}
203 keybindings={keybindings}
204 disabled={saving}
205 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
206 />
207 )}
208 {t3File.status === "invalid" ? (
209 <SettingsRow
210 title="t3.json is invalid"
211 description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values."
212 className="text-warning"
213 />
214 ) : null}
215 <ProjectScriptEditorDialog
216 request={request}
217 scripts={scripts}
218 onSubmit={submit}
219 onDelete={(id) =>
220 void persist((current) => current.filter((script) => script.id !== id), id, null)
221 }
222 onClose={() => setRequest(null)}
223 />
224 </SettingsSection>
225 );
226}
apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx deleted-114
...@@ -1,114 +0,0 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import { PlusIcon } from "lucide-react";
4import { useState } from "react";
5import { useEnvironments } from "../../state/environments";
6import {
7 EMPTY_PROJECT_SCRIPT_INPUT,
8 editorRequestForScript,
9 ProjectScriptEditorDialog,
10 type ProjectScriptEditorRequest,
11} from "../projectScriptEditor";
12import { Button } from "../ui/button";
13import { ProjectActionsList } from "./ProjectActionsList";
14import { useProjectScriptSettings } from "./ProjectSettingsPanel";
15import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
16
17export function ProjectDefaultActionsSettings({
18 environmentId,
19}: {
20 environmentId: EnvironmentId | null;
21}) {
22 const { environments } = useEnvironments();
23 const targets = environments.filter(
24 (environment) =>
25 (environmentId === null || environment.environmentId === environmentId) &&
26 environment.connection.phase === "connected" &&
27 environment.serverConfig !== null,
28 );
29 const representative = targets[0]?.serverConfig;
30 const scripts = representative?.settings.defaultProjectScripts ?? [];
31 const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
32 const mixed = targets.some(
33 (target) =>
34 JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !==
35 JSON.stringify(scripts),
36 );
37 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
38 const { saving, persist, submit } = useProjectScriptSettings(
39 targets.flatMap(({ environmentId, serverConfig }) =>
40 serverConfig
41 ? [
42 {
43 environmentId,
44 settings: serverConfig.settings,
45 keybindings: serverConfig.keybindings,
46 },
47 ]
48 : [],
49 ),
50 );
51
52 return (
53 <SettingsSection title="Actions">
54 <SettingsRow
55 title="Import scripts"
56 aria-disabled
57 description="Select a project to import actions from its checkout's t3.json."
58 control={
59 <Button size="xs" variant="ghost" disabled>
60 Import scripts
61 </Button>
62 }
63 />
64 <SettingsRow
65 title="Default actions"
66 description="Available in every inheriting checkout. Commands run in that checkout or its worktree."
67 resetAction={
68 targets.some(
69 (target) => (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0,
70 ) ? (
71 <SettingResetButton
72 label="default actions"
73 disabled={saving}
74 onClick={() => void persist(() => [])}
75 />
76 ) : null
77 }
78 control={
79 <Button
80 size="xs"
81 variant="outline"
82 disabled={saving || targets.length === 0}
83 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
84 >
85 <PlusIcon className="size-3.5" />
86 Add action
87 </Button>
88 }
89 />
90 {mixed ? (
91 <SettingsRow
92 title="Different actions across machines"
93 description="Select a machine to edit its actions. Adding an action applies to all selected connected machines."
94 />
95 ) : (
96 <ProjectActionsList
97 scripts={scripts}
98 keybindings={keybindings}
99 disabled={saving}
100 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
101 />
102 )}
103 <ProjectScriptEditorDialog
104 request={request}
105 scripts={scripts}
106 onSubmit={submit}
107 onDelete={(id) =>
108 void persist((current) => current.filter((script) => script.id !== id), id, null)
109 }
110 onClose={() => setRequest(null)}
111 />
112 </SettingsSection>
113 );
114}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+315-394
...@@ -1,17 +1,13 @@...@@ -1,17 +1,13 @@
1import {1import {
2 DEFAULT_CLIENT_SETTINGS,
3 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
4 type EnvironmentId,3 EnvironmentId,
5 type ModelSelection,4 type ModelSelection,
6 type ProviderInstanceId,5 type ProviderInstanceId,
7 type ServerSettingsPatch,
8} from "@t3tools/contracts";6} from "@t3tools/contracts";
9import { createModelSelection } from "@t3tools/shared/model";7import { createModelSelection } from "@t3tools/shared/model";
10import { useNavigate } from "@tanstack/react-router";8import { useNavigate } from "@tanstack/react-router";
11import { useRef, useState } from "react";
12import { Trash2Icon } from "lucide-react";
139
14import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";10import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
15import { getCustomModelOptionsByInstance } from "../../modelSelection";11import { getCustomModelOptionsByInstance } from "../../modelSelection";
16import {12import {
17 applyProviderInstanceSettings,13 applyProviderInstanceSettings,
...@@ -19,59 +15,47 @@ import {...@@ -19,59 +15,47 @@ import {
19 resolveDefaultProviderModelSelection,15 resolveDefaultProviderModelSelection,
20 sortProviderInstanceEntries,16 sortProviderInstanceEntries,
21} from "../../providerInstances";17} from "../../providerInstances";
22import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";18import { useEnvironments } from "../../state/environments";
23import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";19import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
24import { useAtomCommand } from "../../state/use-atom-command";
25import { resolveEnvModeLabel } from "../BranchToolbar.logic";20import { resolveEnvModeLabel } from "../BranchToolbar.logic";
26import { ProviderModelPicker } from "../chat/ProviderModelPicker";21import { ProviderModelPicker } from "../chat/ProviderModelPicker";
22import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
27import { TraitsPicker } from "../chat/TraitsPicker";23import { TraitsPicker } from "../chat/TraitsPicker";
28import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
29import { toastManager } from "../ui/toast";25import { toastManager } from "../ui/toast";
30import { Switch } from "../ui/switch";26import { Switch } from "../ui/switch";
31import { Button } from "../ui/button";27import type { ProjectSettingsCategory } from "./ProjectSettingsPanel";
32import { Input } from "../ui/input";
33import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel";
34import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings";
35import { searchableSetting } from "./settingsSearch";28import { searchableSetting } from "./settingsSearch";
29import { useSettingsScope } from "./SettingsScopeContext";
36import {30import {
37 SETTINGS_PICKER_TRIGGER_CLASSNAME,31 SETTINGS_PICKER_TRIGGER_CLASSNAME,
38 SettingResetButton,32 SettingResetButton,
39 SettingsPageContainer,
40 SettingsRow,33 SettingsRow,
41 SettingsSection,34 SettingsSection,
42} from "./settingsLayout";35} from "./settingsLayout";
36import {
37 useScopedSettings,
38 useScopedSettingsMixed,
39 useScopedSettingSource,
40 useUpdateScopedSettings,
41} from "./useScopedSettings";
4342
44/** Defaults are written only to the machines selected on the projects settings page. */43/**
45export function ProjectDefaultsSettings({44 * Rows for the settings a project may override. The same rows edit
46 environmentId,45 * environment defaults at an environment scope and project overrides at a
47}: {46 * project or checkout scope; the scoped hooks route the write.
48 environmentId: EnvironmentId | null;47 */
49}) {48export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
50 const { environments } = useEnvironments();49 const { scope, target, targets, connectedEnvironments } = useSettingsScope();
51 const primaryEnvironmentId = usePrimaryEnvironmentId();50 const settings = useScopedSettings();
52 const clientSettings = useClientSettings();51 const updateSettings = useUpdateScopedSettings();
53 const updateClientSettings = useUpdateClientSettings();
54 const navigate = useNavigate();52 const navigate = useNavigate();
55 const updateSettings = useAtomCommand(53 const { environments } = useEnvironments();
56 serverEnvironment.updateSettings,54 const representative = target
57 "project defaults update",55 ? environments.find((environment) => environment.environmentId === target.environmentId)
58 );56 : undefined;
59 const savingRef = useRef(new Set<string>());
60 const [saving, setSaving] = useState<ReadonlySet<string>>(new Set());
61 const scoped = environments.filter(
62 (environment) => environmentId === null || environment.environmentId === environmentId,
63 );
64 const targets = scoped.filter(
65 (environment) =>
66 environment.connection.phase === "connected" && environment.serverConfig !== null,
67 );
68 const representative =
69 targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0];
70 const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS;
71 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;57 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
72 const settings = { ...serverSettings, ...clientSettings };58 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);
73 const storedSelection = serverSettings.defaultModelSelection;
74 const selection = resolveDefaultProviderModelSelection(providers, storedSelection);
75 const entries = sortProviderInstanceEntries(59 const entries = sortProviderInstanceEntries(
76 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),60 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
77 );61 );
...@@ -82,36 +66,46 @@ export function ProjectDefaultsSettings({...@@ -82,36 +66,46 @@ export function ProjectDefaultsSettings({
82 selection?.model,66 selection?.model,
83 );67 );
84 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);68 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
85 const mixedModel = targets.some(69 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);
86 (target) =>70 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
87 JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !==71 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
88 JSON.stringify(storedSelection),72 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
89 );73 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
90 const mixedWorkspace = targets.some(74 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
91 (target) =>75 const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
92 target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode,76 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
93 );77 const unavailable = connectedEnvironments.length === 0;
94 const mixedBrowser = targets.some(78
95 (target) =>79 // A checkout's t3.json wins over the environment default when the project
96 target.serverConfig?.settings.enableAgentBrowserAccess !==80 // has no override of its own; show which one "inherit" resolves to.
97 serverSettings.enableAgentBrowserAccess,81 const checkout = scope.kind === "checkout" ? scope.checkout : null;
98 );82 // The query is disabled without a checkout, so any id satisfies the hook.
99 const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key);83 const t3File = useT3ProjectFileState(
100 const mixedAutoPull = targets.some(84 checkout?.environmentId ?? EnvironmentId.make("none"),
101 (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull,85 category === "general" && checkout ? checkout.workspaceRoot : null,
102 );86 );
87 const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null;
88 const inheritedEnvModeLabel =
89 workspaceSource === "project"
90 ? null
91 : repositoryEnvMode
92 ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)`
93 : null;
10394
104 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {95 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
105 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);96 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
106 for (const target of targets) {97 for (const candidate of targets) {
107 const config = target.serverConfig;98 const environment = environments.find(
99 (entry) => entry.environmentId === candidate.environmentId,
100 );
101 const config = environment?.serverConfig;
108 if (!config) continue;102 if (!config) continue;
109 const entry = applyProviderInstanceSettings(103 const entry = applyProviderInstanceSettings(
110 deriveProviderInstanceEntries(config.providers),104 deriveProviderInstanceEntries(config.providers),
111 config.settings,105 candidate.settings,
112 ).find((candidate) => candidate.instanceId === instanceId);106 ).find((option) => option.instanceId === instanceId);
113 const options = getCustomModelOptionsByInstance(107 const options = getCustomModelOptionsByInstance(
114 { ...config.settings, ...clientSettings },108 { ...settings, ...candidate.settings },
115 config.providers,109 config.providers,
116 ).get(instanceId);110 ).get(instanceId);
117 if (111 if (
...@@ -120,355 +114,282 @@ export function ProjectDefaultsSettings({...@@ -120,355 +114,282 @@ export function ProjectDefaultsSettings({
120 entry.driverKind !== sourceEntry?.driverKind ||114 entry.driverKind !== sourceEntry?.driverKind ||
121 !options?.some((option) => option.slug === model && !option.isUnavailable)115 !options?.some((option) => option.slug === model && !option.isUnavailable)
122 ) {116 ) {
123 return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`;117 return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`;
124 }118 }
125 }119 }
126 return null;120 return null;
127 }121 }
128122
129 async function save(patch: ServerSettingsPatch) {123 const setModel = (value: ModelSelection | null) => {
130 const keys = Object.keys(patch);124 const reason = value ? modelDisabledReason(value.instanceId, value.model) : null;
131 if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return;
132 const nextModel = patch.defaultModelSelection;
133 const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null;
134 if (reason) {125 if (reason) {
135 toastManager.add({ type: "error", title: "Default model not saved", description: reason });126 toastManager.add({ type: "error", title: "Default model not saved", description: reason });
136 return;127 return;
137 }128 }
138 for (const key of keys) savingRef.current.add(key);129 updateSettings({ defaultModelSelection: value });
139 setSaving(new Set(savingRef.current));130 };
140 try {
141 const results = await Promise.all(
142 targets.map((target) =>
143 updateSettings({ environmentId: target.environmentId, input: { patch } }),
144 ),
145 );
146 const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure");
147 if (failedTargets.length > 0) {
148 toastManager.add({
149 type: "error",
150 title: "Project defaults not saved on every machine",
151 description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`,
152 });
153 }
154 } finally {
155 for (const key of keys) savingRef.current.delete(key);
156 setSaving(new Set(savingRef.current));
157 }
158 }
159131
160 const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value });
161 return (132 return (
162 <SettingsPageContainer>133 <SettingsSection
163 <SettingsSection134 id={
164 id={searchableSetting("project-defaults").id}135 category === "general"
165 title="Project defaults"136 ? "project-defaults"
166 hideTitle137 : category === "integrations"
167 >138 ? "browser-access"
168 <SettingsRow139 : "source-control-defaults"
169 title="Name"140 }
170 aria-disabled141 title={
171 description="Select a project to change its name."142 category === "general"
172 control={143 ? "New threads"
173 <Input144 : category === "integrations"
174 size="sm"145 ? "Browser"
175 className="w-full sm:w-64"146 : "Repositories"
176 aria-label="Project name"147 }
177 placeholder="Select a project"148 >
178 disabled149 {category === "general" ? (
179 />150 <>
180 }151 <SettingsRow
181 />152 serverScoped
182 <SettingsRow153 settingKeys={["defaultModelSelection"]}
183 title="Project icon"154 mixed={mixedModel}
184 aria-disabled155 id="default-model"
185 description="Select a project to change its icon."156 title="Model"
186 control={157 description={
187 <div className="flex items-center gap-2">158 isProjectScope
188 <Button size="sm" variant="outline" disabled>159 ? "Model for new threads in this project."
189 Choose icon160 : "Default model for new threads. Projects can override it."
190 </Button>161 }
191 <Button size="sm" variant="outline" disabled>162 status={
192 Choose file163 unavailable || mixedModel || modelSource === "project"
193 </Button>164 ? undefined
194 </div>165 : settings.defaultModelSelection === null
195 }
196 />
197 {scoped.length > targets.length || targets.length === 0 ? (
198 <p role="status" className="px-4 py-3 text-sm text-muted-foreground">
199 {targets.length === 0
200 ? "Connect a machine to change its project defaults."
201 : "Changes apply to connected machines only. Offline machines keep their current defaults."}
202 </p>
203 ) : null}
204 <SettingsRow
205 title="Model"
206 description="Default model for new threads. Projects can override it."
207 status={
208 targets.length === 0
209 ? undefined
210 : mixedModel
211 ? "Differs by machine"
212 : storedSelection === null
213 ? "Automatic"166 ? "Automatic"
214 : undefined167 : undefined
215 }168 }
216 resetAction={169 resetAction={
217 storedSelection !== null || mixedModel ? (170 settings.defaultModelSelection !== null ? (
218 <SettingResetButton171 <SettingResetButton label="default model" onClick={() => setModel(null)} />
219 label="default model"172 ) : null
220 disabled={disabled("defaultModelSelection")}173 }
221 onClick={() => setModel(null)}174 control={
222 />175 selection && activeEntry ? (
223 ) : null176 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
224 }177 <ProviderModelPicker
225 control={178 activeInstanceId={selection.instanceId}
226 selection && activeEntry ? (
227 <fieldset
228 disabled={disabled("defaultModelSelection")}
229 className="flex min-w-0 flex-wrap items-center justify-end gap-1.5 disabled:opacity-50"
230 >
231 <ProviderModelPicker
232 activeInstanceId={selection.instanceId}
233 model={selection.model}
234 lockedProvider={null}
235 instanceEntries={entries}
236 modelOptionsByInstance={modelOptions}
237 disabled={disabled("defaultModelSelection")}
238 triggerVariant="outline"
239 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
240 getModelDisabledReason={modelDisabledReason}
241 onOpenProviderSetup={(instanceId) => {
242 if (representative)
243 void navigate({
244 to: "/settings/providers",
245 search: { environmentId: representative.environmentId, instanceId },
246 });
247 }}
248 onInstanceModelChange={(instanceId, model) =>
249 setModel(createModelSelection(instanceId, model))
250 }
251 />
252 {!mixedModel ? (
253 <TraitsPicker
254 provider={activeEntry.driverKind}
255 models={activeEntry.models}
256 model={selection.model}179 model={selection.model}
257 prompt=""180 lockedProvider={null}
258 onPromptChange={() => {}}181 instanceEntries={entries}
259 modelOptions={selection.options ?? []}182 modelOptionsByInstance={modelOptions}
260 allowPromptInjectedEffort={false}
261 planModeEnabled={settings.planModeEnabled}
262 triggerVariant="outline"183 triggerVariant="outline"
263 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}184 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
264 onModelOptionsChange={(options) =>185 {...(mixedModel ? { triggerLabel: "Mixed" } : {})}
265 setModel(createModelSelection(selection.instanceId, selection.model, options))186 getModelDisabledReason={modelDisabledReason}
187 onOpenProviderSetup={(instanceId) => {
188 if (representative)
189 void navigate({
190 to: "/settings/providers",
191 search: { environmentId: representative.environmentId, instanceId },
192 });
193 }}
194 onInstanceModelChange={(instanceId, model) =>
195 setModel(createModelSelection(instanceId, model))
266 }196 }
267 />197 />
268 ) : null}198 {!mixedModel ? (
269 </fieldset>199 <TraitsPicker
270 ) : (200 provider={activeEntry.driverKind}
271 <span className="text-sm text-muted-foreground">No providers available</span>201 models={activeEntry.models}
272 )202 model={selection.model}
273 }203 prompt=""
274 />204 onPromptChange={() => {}}
275 <SettingsRow205 modelOptions={selection.options ?? []}
276 id={searchableSetting("new-threads").id}206 allowPromptInjectedEffort={false}
277 title="Workspace"207 planModeEnabled={settings.planModeEnabled}
278 description="Where new threads start, unless overridden by the project or t3.json."208 triggerVariant="outline"
279 resetAction={209 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
280 mixedWorkspace ||210 onModelOptionsChange={(options) =>
281 serverSettings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (211 setModel(
282 <SettingResetButton212 createModelSelection(selection.instanceId, selection.model, options),
283 label="default workspace"213 )
284 disabled={disabled("defaultThreadEnvMode")}214 }
285 onClick={() =>215 />
286 void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode })216 ) : null}
287 }217 </div>
288 />218 ) : (
289 ) : null219 <span className="text-sm text-muted-foreground">No providers available</span>
290 }220 )
291 control={221 }
292 <Select222 />
293 disabled={disabled("defaultThreadEnvMode")}223 <SettingsRow
294 value={mixedWorkspace ? "mixed" : serverSettings.defaultThreadEnvMode}224 serverScoped
295 onValueChange={(value) => {225 settingKeys={["defaultThreadEnvMode"]}
296 if (value === "local" || value === "worktree")226 mixed={mixedWorkspace}
297 void save({ defaultThreadEnvMode: value });227 id={searchableSetting("new-threads").id}
298 }}228 title="Workspace"
299 >229 description={
300 <SelectTrigger size="sm" aria-label="Default workspace">230 isProjectScope
301 <SelectValue>231 ? "Where new threads in this project start. A t3.json preference applies when the project has no override."
302 {targets.length === 0232 : "Where new threads start, unless overridden by the project or t3.json."
303 ? "Unavailable"233 }
304 : mixedWorkspace234 status={
305 ? "Differs by machine"235 inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
306 : resolveEnvModeLabel(serverSettings.defaultThreadEnvMode)}236 }
307 </SelectValue>237 resetAction={
308 </SelectTrigger>238 settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
309 <SelectPopup align="end" alignItemWithTrigger={false}>239 <SettingResetButton
310 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>240 label="default workspace"
311 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>241 onClick={() =>
312 </SelectPopup>242 updateSettings({
313 </Select>243 defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
314 }244 })
315 />245 }
316 <SettingsRow246 />
317 title="Automatically pull"247 ) : null
318 description="Keeps the default branch current when the checkout has no local changes or commits. Projects can override it."248 }
319 status={mixedAutoPull ? "Differs by machine" : undefined}249 control={
320 resetAction={250 <Select
321 serverSettings.defaultAutoPull || mixedAutoPull ? (251 value={mixedWorkspace ? null : settings.defaultThreadEnvMode}
322 <SettingResetButton252 onValueChange={(value) => {
323 label="default automatic pull"253 if (value === "local" || value === "worktree")
324 tooltip="Reset automatic pull to off"254 updateSettings({ defaultThreadEnvMode: value });
325 disabled={disabled("defaultAutoPull")}255 }}
326 onClick={() => void save({ defaultAutoPull: false })}256 >
257 <SelectTrigger size="sm" aria-label="Default workspace">
258 <SelectValue>
259 {(value: string | null) =>
260 value === "local" || value === "worktree"
261 ? resolveEnvModeLabel(value)
262 : unavailable
263 ? "Unavailable"
264 : "Mixed"
265 }
266 </SelectValue>
267 </SelectTrigger>
268 <SelectPopup align="end" alignItemWithTrigger={false}>
269 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
270 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
271 </SelectPopup>
272 </Select>
273 }
274 />
275 </>
276 ) : category === "source-control" ? (
277 <>
278 <SettingsRow
279 serverScoped
280 settingKeys={["defaultAutoPull"]}
281 mixed={mixedAutoPull}
282 id="automatic-pull"
283 title="Automatically pull"
284 description={
285 isProjectScope
286 ? "Keeps this project's default branch current when the checkout has no local changes or commits."
287 : "Keeps the default branch current when the checkout has no local changes or commits. Projects can override it."
288 }
289 resetAction={
290 settings.defaultAutoPull ? (
291 <SettingResetButton
292 label="default automatic pull"
293 tooltip="Reset automatic pull to off"
294 onClick={() => updateSettings({ defaultAutoPull: false })}
295 />
296 ) : null
297 }
298 control={
299 <Switch
300 aria-label="Default automatic pull"
301 mixed={mixedAutoPull}
302 checked={mixedAutoPull ? false : settings.defaultAutoPull}
303 onCheckedChange={(enabled) => updateSettings({ defaultAutoPull: enabled })}
327 />304 />
328 ) : null305 }
329 }306 />
330 control={307 <SettingsRow
331 <Switch308 serverScoped
332 aria-label="Default automatic pull"309 settingKeys={["pullRequestMergeMethod"]}
333 checked={serverSettings.defaultAutoPull}310 mixed={mixedMergeMethod}
334 disabled={disabled("defaultAutoPull")}311 {...searchableSetting("pull-request-merge-method")}
335 onCheckedChange={(enabled) => void save({ defaultAutoPull: enabled })}312 description={
336 />313 isProjectScope
337 }314 ? "Pull requests in this project start with this method."
338 />315 : "Pull requests start with this method. Last selected reuses whatever you chose most recently on this device."
339 <SettingsRow316 }
340 id={searchableSetting("agent-browser-access").id}317 resetAction={
341 title="Agent browser access"318 settings.pullRequestMergeMethod !== null ? (
342 description="Allow agents to use the shared browser. Projects can override it."319 <SettingResetButton
343 resetAction={320 label="default merge method"
344 mixedBrowser ||321 tooltip="Reset to last selected"
345 serverSettings.enableAgentBrowserAccess !==322 onClick={() => updateSettings({ pullRequestMergeMethod: null })}
323 />
324 ) : null
325 }
326 control={
327 <Select
328 value={mixedMergeMethod ? null : (settings.pullRequestMergeMethod ?? "last")}
329 onValueChange={(value) => {
330 if (value === "last") updateSettings({ pullRequestMergeMethod: null });
331 else if (value === "merge" || value === "squash" || value === "rebase")
332 updateSettings({ pullRequestMergeMethod: value });
333 }}
334 >
335 <SelectTrigger size="sm" aria-label="Default pull request merge method">
336 <SelectValue>
337 {(value: string | null) =>
338 value === "merge" || value === "squash" || value === "rebase"
339 ? PULL_REQUEST_MERGE_METHOD_LABELS[value]
340 : value === "last"
341 ? "Last selected"
342 : "Mixed"
343 }
344 </SelectValue>
345 </SelectTrigger>
346 <SelectPopup align="end" alignItemWithTrigger={false}>
347 <SelectItem value="last">Last selected</SelectItem>
348 <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem>
349 <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem>
350 <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem>
351 </SelectPopup>
352 </Select>
353 }
354 />
355 </>
356 ) : (
357 <>
358 <SettingsRow
359 serverScoped
360 settingKeys={["enableAgentBrowserAccess"]}
361 mixed={mixedBrowser}
362 id={searchableSetting("agent-browser-access").id}
363 title="Agent browser access"
364 description={
365 isProjectScope
366 ? "Allow agents in this project to use the shared browser. Applies when the agent session next starts."
367 : "Allow agents to use the shared browser. Projects can override it."
368 }
369 resetAction={
370 settings.enableAgentBrowserAccess !==
346 DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? (371 DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? (
347 <SettingResetButton372 <SettingResetButton
348 label="default browser access"373 label="default browser access"
349 disabled={disabled("enableAgentBrowserAccess")}374 onClick={() =>
350 onClick={() =>375 updateSettings({
351 void save({376 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,
352 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,377 })
353 })378 }
354 }379 />
355 />380 ) : null
356 ) : null381 }
357 }382 control={
358 control={383 <Switch
359 <Select384 aria-label="Agent browser access"
360 disabled={disabled("enableAgentBrowserAccess")}385 mixed={mixedBrowser}
361 value={386 checked={mixedBrowser ? false : settings.enableAgentBrowserAccess}
362 mixedBrowser387 onCheckedChange={(enabled) => updateSettings({ enableAgentBrowserAccess: enabled })}
363 ? "mixed"
364 : serverSettings.enableAgentBrowserAccess
365 ? "enabled"
366 : "disabled"
367 }
368 onValueChange={(value) => {
369 if (value === "enabled" || value === "disabled")
370 void save({ enableAgentBrowserAccess: value === "enabled" });
371 }}
372 >
373 <SelectTrigger size="sm" aria-label="Default agent browser access">
374 <SelectValue>
375 {targets.length === 0
376 ? "Unavailable"
377 : mixedBrowser
378 ? "Differs by machine"
379 : serverSettings.enableAgentBrowserAccess
380 ? "Enabled"
381 : "Disabled"}
382 </SelectValue>
383 </SelectTrigger>
384 <SelectPopup align="end" alignItemWithTrigger={false}>
385 <SelectItem value="enabled">Enabled</SelectItem>
386 <SelectItem value="disabled">Disabled</SelectItem>
387 </SelectPopup>
388 </Select>
389 }
390 />
391 </SettingsSection>
392 <SettingsSection title="Checkout">
393 <SettingsRow
394 title="Checkout"
395 aria-disabled
396 description="Select a project to choose one of its checkouts."
397 control={
398 <Select disabled>
399 <SelectTrigger size="sm" aria-label="Checkout">
400 <SelectValue placeholder="Select a project" />
401 </SelectTrigger>
402 </Select>
403 }
404 />
405 <SettingsRow
406 title="Project grouping"
407 description="Default grouping across all machines in this client. Individual checkout overrides are preserved."
408 resetAction={
409 clientSettings.sidebarProjectGroupingMode !==
410 DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode ? (
411 <SettingResetButton
412 label="default project grouping"
413 onClick={() =>
414 void updateClientSettings({
415 sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode,
416 })
417 }
418 />388 />
419 ) : null389 }
420 }390 />
421 control={391 </>
422 <Select392 )}
423 value={clientSettings.sidebarProjectGroupingMode}393 </SettingsSection>
424 onValueChange={(value) => {
425 if (value === "repository" || value === "repository_path" || value === "separate")
426 void updateClientSettings({ sidebarProjectGroupingMode: value });
427 }}
428 >
429 <SelectTrigger size="sm" aria-label="Default project grouping">
430 <SelectValue>
431 {PROJECT_GROUPING_MODE_LABELS[clientSettings.sidebarProjectGroupingMode]}
432 </SelectValue>
433 </SelectTrigger>
434 <SelectPopup align="end" alignItemWithTrigger={false}>
435 <SelectItem value="repository">
436 {PROJECT_GROUPING_MODE_LABELS.repository}
437 </SelectItem>
438 <SelectItem value="repository_path">
439 {PROJECT_GROUPING_MODE_LABELS.repository_path}
440 </SelectItem>
441 <SelectItem value="separate">{PROJECT_GROUPING_MODE_LABELS.separate}</SelectItem>
442 </SelectPopup>
443 </Select>
444 }
445 />
446 <SettingsRow
447 title="Remove checkout"
448 aria-disabled
449 description="Select a project to remove one of its checkouts."
450 control={
451 <Button size="sm" variant="destructive-outline" disabled>
452 <Trash2Icon className="size-3.5" />
453 Remove checkout
454 </Button>
455 }
456 />
457 </SettingsSection>
458 <ProjectDefaultActionsSettings environmentId={environmentId} />
459 <SettingsSection title="Danger">
460 <SettingsRow
461 title="Remove project"
462 aria-disabled
463 description="Select a project to remove its entries and threads. Files on disk are not touched."
464 control={
465 <Button size="sm" variant="destructive-outline" disabled>
466 <Trash2Icon />
467 Remove project
468 </Button>
469 }
470 />
471 </SettingsSection>
472 </SettingsPageContainer>
473 );394 );
474}395}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+54-977
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { useAtomValue } from "@effect/atom-react";
2import {1import {
3 isAtomCommandInterrupted,2 isAtomCommandInterrupted,
4 mapAtomCommandResult,3 mapAtomCommandResult,
...@@ -8,102 +7,28 @@ import {...@@ -8,102 +7,28 @@ import {
8} from "@t3tools/client-runtime/state/runtime";7} from "@t3tools/client-runtime/state/runtime";
9import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";8import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
10import { AsyncResult } from "effect/unstable/reactivity";9import { AsyncResult } from "effect/unstable/reactivity";
11import {10import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
12 deriveProjectGroupingOverrideKey,11import { useLocation, useNavigate } from "@tanstack/react-router";
13 selectProjectGroupingSettings,
14} from "../../logicalProject";
15import {
16 type EnvironmentId,
17 type ModelSelection,
18 type ProjectIconOverride,
19 type ProjectId,
20 type ProjectScript,
21 type ResolvedKeybindingsConfig,
22 type ServerSettings,
23 type ProviderDriverKind,
24 type PullRequestMergeMethod,
25 type SidebarProjectGroupingMode,
26 type T3ProjectFileScript,
27 type ThreadEnvMode,
28} from "@t3tools/contracts";
29import { resolveEnvModeLabel } from "../BranchToolbar.logic";
30import { createModelSelection } from "@t3tools/shared/model";
31import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
32import {
33 projectScriptsInheritDefaults,
34 resolveProjectScripts,
35} from "@t3tools/shared/projectScripts";
36import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
37import { useNavigate } from "@tanstack/react-router";
38import * as Equal from "effect/Equal";
39import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
40import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";13import { Trash2Icon } from "lucide-react";
41import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";14import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
4215
43import { useComposerDraftStore } from "../../composerDraftStore";16import { useComposerDraftStore } from "../../composerDraftStore";
44import {
45 useClientSettings,
46 useEnvironmentSettings,
47 useUpdateClientSettings,
48} from "../../hooks/useSettings";
49import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
50import { ProjectActionsList } from "./ProjectActionsList";
51import { isElectron } from "../../env";
52import {
53 decodeProjectScriptKeybindingRule,
54 keybindingValueForCommand,
55} from "../../lib/projectScriptKeybindings";
56import {
57 buildProjectScript,
58 commandForProjectScript,
59 nextProjectScriptId,
60} from "../../projectScripts";
61import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";17import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
62import { readLocalApi } from "../../localApi";18import { readLocalApi } from "../../localApi";
63import {
64 applyProviderInstanceSettings,
65 deriveProviderInstanceEntries,
66 resolveDefaultProviderModelSelection,
67 sortProviderInstanceEntries,
68} from "../../providerInstances";
69import { getCustomModelOptionsByInstance } from "../../modelSelection";
70import {19import {
71 type SidebarProjectGroupMember,20 type SidebarProjectGroupMember,
72 type SidebarProjectSnapshot,21 type SidebarProjectSnapshot,
73} from "../../sidebarProjectGrouping";22} from "../../sidebarProjectGrouping";
74import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";23import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
75import { useProjects, useThreadShells } from "../../state/entities";24import { useThreadShells } from "../../state/entities";
76import { projectEnvironment } from "../../state/projects";25import { projectEnvironment } from "../../state/projects";
77import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
78import { useAtomCommand } from "../../state/use-atom-command";26import { useAtomCommand } from "../../state/use-atom-command";
79import { ProviderModelPicker } from "../chat/ProviderModelPicker";
80import { TraitsPicker } from "../chat/TraitsPicker";
81import { ProjectFavicon } from "../ProjectFavicon";27import { ProjectFavicon } from "../ProjectFavicon";
82import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
83import {
84 EMPTY_PROJECT_SCRIPT_INPUT,
85 editorRequestForScript,
86 ProjectScriptEditorDialog,
87 ScriptIcon,
88 type NewProjectScriptInput,
89 type ProjectScriptEditorRequest,
90} from "../projectScriptEditor";
91import { Button } from "../ui/button";28import { Button } from "../ui/button";
92import { Input } from "../ui/input";29import { Input } from "../ui/input";
93import {
94 Menu,
95 MenuGroup,
96 MenuGroupLabel,
97 MenuItem,
98 MenuPopup,
99 MenuSeparator,
100 MenuTrigger,
101} from "../ui/menu";
102import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
103import { Switch } from "../ui/switch";
104import { stackedThreadToast, toastManager } from "../ui/toast";30import { stackedThreadToast, toastManager } from "../ui/toast";
105import {31import {
106 SETTINGS_PICKER_TRIGGER_CLASSNAME,
107 SettingResetButton,32 SettingResetButton,
108 SettingsPageContainer,33 SettingsPageContainer,
109 SettingsRow,34 SettingsRow,
...@@ -113,6 +38,7 @@ import {...@@ -113,6 +38,7 @@ import {
113 canPickExternalProjectFavicon,38 canPickExternalProjectFavicon,
114 ProjectFaviconPickerDialog,39 ProjectFaviconPickerDialog,
115} from "./ProjectFaviconPickerDialog";40} from "./ProjectFaviconPickerDialog";
41import { ProjectActionsSettings } from "./ProjectActionsSettings";
116import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";42import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
117import { useSettingsProjectGroups } from "./useSettingsProjectGroups";43import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
11844
...@@ -122,16 +48,12 @@ const ProjectIconPickerDialog = lazy(() =>...@@ -122,16 +48,12 @@ const ProjectIconPickerDialog = lazy(() =>
122 })),48 })),
123);49);
12450
125export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
126 repository: "Group by repository",
127 repository_path: "Group by repository path",
128 separate: "Keep separate",
129};
130
131function memberKey(member: { environmentId: string; id: string }): string {51function memberKey(member: { environmentId: string; id: string }): string {
132 return `${member.environmentId}:${member.id}`;52 return `${member.environmentId}:${member.id}`;
133}53}
13454
55export type ProjectSettingsCategory = "general" | "integrations" | "source-control";
56
135export function ProjectSettingsPanel({57export function ProjectSettingsPanel({
136 projectKey,58 projectKey,
137 environmentId = null,59 environmentId = null,
...@@ -142,7 +64,8 @@ export function ProjectSettingsPanel({...@@ -142,7 +64,8 @@ export function ProjectSettingsPanel({
142 checkoutKey?: string | null;64 checkoutKey?: string | null;
143}) {65}) {
144 const groups = useSettingsProjectGroups();66 const groups = useSettingsProjectGroups();
145 const navigate = useNavigate();67 const navigate = useNavigate({ from: "/settings" });
68 const pathname = useLocation({ select: (location) => location.pathname });
14669
147 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;70 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
148 const members = useMemo(71 const members = useMemo(
...@@ -189,17 +112,17 @@ export function ProjectSettingsPanel({...@@ -189,17 +112,17 @@ export function ProjectSettingsPanel({
189 );112 );
190 if (successor) {113 if (successor) {
191 void navigate({114 void navigate({
192 to: "/settings/projects",115 to: pathname,
193 search: {116 search: () => ({
194 project: successor.projectKey,117 project: successor.projectKey,
195 machine: environmentId ?? undefined,118 machine: environmentId ?? undefined,
196 checkout: checkoutKey ?? undefined,119 checkout: checkoutKey ?? undefined,
197 },120 }),
198 replace: true,121 replace: true,
199 hashScrollIntoView: false,122 hashScrollIntoView: false,
200 });123 });
201 }124 }
202 }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]);125 }, [groups, navigate, pathname, projectKey, members.length, environmentId, checkoutKey]);
203126
204 if (!selected) {127 if (!selected) {
205 return (128 return (
...@@ -231,146 +154,6 @@ export function ProjectSettingsPanel({...@@ -231,146 +154,6 @@ export function ProjectSettingsPanel({
231 );154 );
232}155}
233156
234function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {
235 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
236 const error = squashAtomCommandFailure(result);
237 toastManager.add({
238 type: "error",
239 title: "Failed to save project actions",
240 description: error instanceof Error ? error.message : "An error occurred.",
241 });
242 }
243 return mapAtomCommandResult(result, () => undefined);
244}
245
246export function useProjectScriptSettings(
247 targets: readonly {
248 environmentId: EnvironmentId;
249 settings: ServerSettings;
250 keybindings: ResolvedKeybindingsConfig;
251 project?: { id: ProjectId; scripts: readonly ProjectScript[] };
252 }[],
253) {
254 const projects = useProjects();
255 const [saving, setSaving] = useState(false);
256 const savingRef = useRef(false);
257 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
258 const upsertKeybinding = useAtomCommand(
259 serverEnvironment.upsertKeybinding,
260 "action shortcut update",
261 );
262 const removeKeybinding = useAtomCommand(
263 serverEnvironment.removeKeybinding,
264 "action shortcut removal",
265 );
266
267 async function persist(
268 transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
269 scriptId?: string,
270 keybinding?: string | null,
271 ): Promise<AtomCommandResult<void, unknown>> {
272 if (savingRef.current || targets.length === 0) {
273 const message = "No available machine, or another action change is saving.";
274 toastManager.add({ type: "error", title: "Actions not saved", description: message });
275 return AsyncResult.failure(Cause.fail(new Error(message)));
276 }
277 savingRef.current = true;
278 setSaving(true);
279 try {
280 for (const { environmentId, settings, keybindings, project } of targets) {
281 const current = project
282 ? resolveProjectScripts(settings, project)
283 : settings.defaultProjectScripts;
284 const nextScripts = transform(current);
285 const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
286 const result = await updateSettings({
287 environmentId,
288 input: {
289 patch: project
290 ? { projectScriptOverrides: { [project.id]: nextScripts } }
291 : { defaultProjectScripts: nextScripts ?? [] },
292 },
293 });
294 if (result._tag === "Failure") return reportScriptFailure(result);
295 if (!isElectron) continue;
296 const changedIds = scriptId
297 ? [scriptId]
298 : current
299 .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
300 .map((script) => script.id);
301 for (const id of changedIds) {
302 const command = commandForProjectScript(id);
303 const previousValue = keybindingValueForCommand(keybindings, command);
304 const previous = previousValue
305 ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
306 : null;
307 const next = decodeProjectScriptKeybindingRule({ keybinding, command });
308 const retainedElsewhere =
309 !nextScripts?.some((script) => script.id === id) &&
310 ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
311 Object.entries(settings.projectScriptOverrides).some(
312 ([projectId, scripts]) =>
313 projectId !== project?.id && scripts?.some((script) => script.id === id),
314 ) ||
315 projects.some(
316 (other) =>
317 other.environmentId === environmentId &&
318 other.id !== project?.id &&
319 (project ? resolveProjectScripts(settings, other) : other.scripts).some(
320 (script) => script.id === id,
321 ),
322 ));
323 const bindingResult = next
324 ? await upsertKeybinding({
325 environmentId,
326 input:
327 previous && previous.key !== next.key ? { ...next, replace: previous } : next,
328 })
329 : previous && !retainedElsewhere
330 ? await removeKeybinding({ environmentId, input: previous })
331 : null;
332 if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
333 }
334 }
335 return AsyncResult.success(undefined);
336 } finally {
337 savingRef.current = false;
338 setSaving(false);
339 }
340 }
341
342 function submit(scriptId: string | null, input: NewProjectScriptInput) {
343 const existingIds = [
344 ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
345 ...targets.flatMap(({ settings, project }) =>
346 [
347 ...settings.defaultProjectScripts,
348 ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []),
349 ...(project?.scripts ?? []),
350 ].map((script) => script.id),
351 ),
352 ];
353 const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
354 const next = buildProjectScript(id, input);
355 return persist(
356 (current) => {
357 const updated = current.map((script) =>
358 script.id === id
359 ? next
360 : input.runOnWorktreeCreate
361 ? { ...script, runOnWorktreeCreate: false }
362 : script,
363 );
364 return scriptId === null ? [...updated, next] : updated;
365 },
366 id,
367 input.keybinding,
368 );
369 }
370
371 return { saving, persist, submit };
372}
373
374function ProjectDetail({157function ProjectDetail({
375 group,158 group,
376 hasOtherMembers,159 hasOtherMembers,
...@@ -378,7 +161,7 @@ function ProjectDetail({...@@ -378,7 +161,7 @@ function ProjectDetail({
378 group: SidebarProjectSnapshot;161 group: SidebarProjectSnapshot;
379 hasOtherMembers: boolean;162 hasOtherMembers: boolean;
380}) {163}) {
381 const navigate = useNavigate();164 const navigate = useNavigate({ from: "/settings" });
382 const primaryEnvironmentId = usePrimaryEnvironmentId();165 const primaryEnvironmentId = usePrimaryEnvironmentId();
383 const { environments } = useEnvironments();166 const { environments } = useEnvironments();
384 const environmentById = useMemo(167 const environmentById = useMemo(
...@@ -389,102 +172,10 @@ function ProjectDetail({...@@ -389,102 +172,10 @@ function ProjectDetail({
389 group.memberProjects.find(172 group.memberProjects.find(
390 (member) => environmentById.get(member.environmentId)?.serverConfig != null,173 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
391 ) ?? group.memberProjects[0]!;174 ) ?? group.memberProjects[0]!;
392 // Provider instances and model options belong to the environment that runs
393 // the project's threads. The hosted app has no primary environment, so
394 // reading them from there would show "No providers available" everywhere.
395 const projectSettings = useEnvironmentSettings(representative.environmentId);
396 const serverProviders =
397 useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ??
398 EMPTY_SERVER_PROVIDERS;
399 const updateClientSettings = useUpdateClientSettings();
400 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
401 const threads = useThreadShells();175 const threads = useThreadShells();
402 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });176 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
403 const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");
404 const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);
405 const savingBrowserAccessRef = useRef(false);
406 const browserOverrides = group.memberProjects.map(
407 (member) =>
408 environmentById.get(member.environmentId)?.serverConfig?.settings
409 .projectAgentBrowserAccessOverrides[member.id],
410 );
411 const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id];
412 const browserMixed = group.memberProjects.some((member, index) => {
413 const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
414 if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false;
415 return (
416 browserOverrides[index] !== browserOverride ||
417 (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !==
418 (browserOverride ?? projectSettings.enableAgentBrowserAccess)
419 );
420 });
421 const setBooleanOverride = async (
422 key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides",
423 enabled: boolean | undefined,
424 ) => {
425 if (savingBrowserAccessRef.current) return;
426 savingBrowserAccessRef.current = true;
427 setSavingBrowserAccess(true);
428 try {
429 const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId));
430 for (const environmentId of environmentIds) {
431 const environment = environmentById.get(environmentId);
432 if (!environment?.serverConfig || environment.connection.phase !== "connected") {
433 toastManager.add({
434 type: "warning",
435 title: "Setting not saved",
436 description: `Connect ${environment?.label ?? "this machine"} and try again.`,
437 });
438 return;
439 }
440 }
441 if (key === "projectAutoPullOverrides" && enabled === undefined) {
442 const result = await updateAllMembers(
443 { autoPull: false },
444 "Failed to reset automatic pull",
445 );
446 if (result._tag === "Failure") return;
447 }
448 for (const environmentId of environmentIds) {
449 const overrides = Object.fromEntries(
450 group.memberProjects
451 .filter((member) => member.environmentId === environmentId)
452 .map((member) => [member.id, enabled ?? null]),
453 );
454 const result = await updateServerSettings({
455 environmentId,
456 input: { patch: { [key]: overrides } },
457 });
458 if (result._tag === "Failure") {
459 reportFailure(
460 `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`,
461 mapAtomCommandResult(result, () => undefined),
462 );
463 return;
464 }
465 }
466 } finally {
467 savingBrowserAccessRef.current = false;
468 setSavingBrowserAccess(false);
469 }
470 };
471 const setBrowserAccess = (enabled: boolean | undefined) =>
472 setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);
473 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });177 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
474 const projectNameEditedRef = useRef(false);178 const projectNameEditedRef = useRef(false);
475 const mergeMethodOverrides = useClientSettings(
476 (settings) => settings.pullRequestMergeMethodOverrides,
477 );
478 const projectMergeMethod = mergeMethodOverrides[group.projectKey];
479 const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => {
480 const nextOverrides = { ...mergeMethodOverrides };
481 if (method === null) {
482 delete nextOverrides[group.projectKey];
483 } else {
484 nextOverrides[group.projectKey] = method;
485 }
486 updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });
487 };
488179
489 const faviconPath = representative.faviconPath ?? null;180 const faviconPath = representative.faviconPath ?? null;
490 const projectIcon = representative.projectIcon ?? null;181 const projectIcon = representative.projectIcon ?? null;
...@@ -516,14 +207,23 @@ function ProjectDetail({...@@ -516,14 +207,23 @@ function ProjectDetail({
516 async (207 async (
517 input: Partial<{208 input: Partial<{
518 title: string;209 title: string;
519 defaultModelSelection: ModelSelection | null;
520 defaultThreadEnvMode: ThreadEnvMode | null;
521 autoPull: boolean;
522 faviconPath: string | null;210 faviconPath: string | null;
523 projectIcon: ProjectIconOverride | null;211 projectIcon: ProjectIconOverride | null;
524 }>,212 }>,
525 failureTitle: string,213 failureTitle: string,
526 ): Promise<AtomCommandResult<void, unknown>> => {214 ): Promise<AtomCommandResult<void, unknown>> => {
215 const unavailable = group.memberProjects.find((member) => {
216 const environment = environmentById.get(member.environmentId);
217 return environment?.connection.phase !== "connected" || !environment.serverConfig;
218 });
219 if (unavailable) {
220 const error = new Error(
221 `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`,
222 );
223 const result: AtomCommandResult<void, unknown> = AsyncResult.failure(Cause.fail(error));
224 reportFailure(failureTitle, result);
225 return result;
226 }
527 for (const member of group.memberProjects) {227 for (const member of group.memberProjects) {
528 const result = mapAtomCommandResult(228 const result = mapAtomCommandResult(
529 await updateProject({229 await updateProject({
...@@ -546,7 +246,7 @@ function ProjectDetail({...@@ -546,7 +246,7 @@ function ProjectDetail({
546 }246 }
547 return AsyncResult.success(undefined);247 return AsyncResult.success(undefined);
548 },248 },
549 [group.memberProjects, reportFailure, updateProject],249 [environmentById, group.memberProjects, reportFailure, updateProject],
550 );250 );
551251
552 const renameGroup = useCallback(252 const renameGroup = useCallback(
...@@ -570,117 +270,6 @@ function ProjectDetail({...@@ -570,117 +270,6 @@ function ProjectDetail({
570 [group.memberProjects, updateAllMembers],270 [group.memberProjects, updateAllMembers],
571 );271 );
572272
573 // ----- default model -----
574 const storedSelection = representative.defaultModelSelection;
575 const resolvedSelection = resolveDefaultProviderModelSelection(
576 serverProviders,
577 storedSelection ?? projectSettings.defaultModelSelection,
578 );
579 const mixedModel = group.memberProjects.some((member) => {
580 const config = environmentById.get(member.environmentId)?.serverConfig;
581 return (
582 !Equal.equals(member.defaultModelSelection, storedSelection) ||
583 (config !== null &&
584 config !== undefined &&
585 environmentById.get(representative.environmentId)?.serverConfig != null &&
586 JSON.stringify(
587 resolveDefaultProviderModelSelection(
588 config.providers,
589 member.defaultModelSelection ?? config.settings.defaultModelSelection,
590 ),
591 ) !== JSON.stringify(resolvedSelection))
592 );
593 });
594 const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
595 const resolvedModel = resolvedSelection?.model ?? null;
596 const instanceEntries = useMemo(
597 () =>
598 sortProviderInstanceEntries(
599 applyProviderInstanceSettings(
600 deriveProviderInstanceEntries(serverProviders),
601 projectSettings,
602 ),
603 ),
604 [serverProviders, projectSettings],
605 );
606 const modelOptionsByInstance = useMemo(
607 () =>
608 getCustomModelOptionsByInstance(
609 projectSettings,
610 serverProviders,
611 resolvedInstanceId,
612 resolvedModel,
613 ),
614 [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],
615 );
616 const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
617 const setDefaultModel = (selection: ModelSelection | null) => {
618 if (selection !== null) {
619 for (const member of group.memberProjects) {
620 const environment = environmentById.get(member.environmentId);
621 const config = environment?.serverConfig;
622 const entry = config
623 ? applyProviderInstanceSettings(
624 deriveProviderInstanceEntries(config.providers),
625 config.settings,
626 ).find((candidate) => candidate.instanceId === selection.instanceId)
627 : undefined;
628 const options = config
629 ? getCustomModelOptionsByInstance(
630 { ...projectSettings, ...config.settings },
631 config.providers,
632 ).get(selection.instanceId)
633 : undefined;
634 if (
635 !entry?.enabled ||
636 !entry.isAvailable ||
637 !options?.some((model) => model.slug === selection.model && !model.isUnavailable)
638 ) {
639 toastManager.add({
640 type: "warning",
641 title: "Project model not saved",
642 description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`,
643 });
644 return;
645 }
646 }
647 }
648 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model");
649 };
650
651 // ----- new-thread workspace mode -----
652 const storedEnvMode = representative.defaultThreadEnvMode ?? null;
653 const mixedWorkspace = group.memberProjects.some(
654 (member) => member.defaultThreadEnvMode !== storedEnvMode,
655 );
656 const setDefaultThreadEnvMode = useCallback(
657 (mode: ThreadEnvMode | null) =>
658 void updateAllMembers(
659 { defaultThreadEnvMode: mode },
660 "Failed to update new-thread workspace",
661 ),
662 [updateAllMembers],
663 );
664
665 const autoPull = resolveProjectAutoPull(
666 projectSettings,
667 representative.id,
668 representative.autoPull,
669 );
670 const autoPullOverridden = group.memberProjects.some(
671 (member) =>
672 member.autoPull ||
673 environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[
674 member.id
675 ] !== undefined,
676 );
677 const mixedAutoPull = group.memberProjects.some((member) => {
678 const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
679 return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull;
680 });
681 const setAutoPull = (enabled: boolean | undefined) =>
682 setBooleanOverride("projectAutoPullOverrides", enabled);
683
684 // ----- project icon -----273 // ----- project icon -----
685 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);274 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
686 const [iconPickerOpen, setIconPickerOpen] = useState(false);275 const [iconPickerOpen, setIconPickerOpen] = useState(false);
...@@ -701,99 +290,7 @@ function ProjectDetail({...@@ -701,99 +290,7 @@ function ProjectDetail({
701 [updateAllMembers],290 [updateAllMembers],
702 );291 );
703292
704 // ----- checkout selection and scripts -----
705 const hasMultipleCheckouts = group.memberProjects.length > 1;293 const hasMultipleCheckouts = group.memberProjects.length > 1;
706 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState<string | null>(null);
707 const selectedCheckoutMatch = group.memberProjects.find(
708 (member) => member.physicalProjectKey === selectedCheckoutKey,
709 );
710 const selectedCheckout = selectedCheckoutMatch ?? representative;
711 const selectedServerConfig = useAtomValue(
712 serverEnvironment.configValueAtom(selectedCheckout.environmentId),
713 );
714 const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
715 const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId);
716 const scripts = resolveProjectScripts(scriptSettings, selectedCheckout);
717 const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout);
718 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
719 const {
720 saving: isSavingScripts,
721 persist: persistScripts,
722 submit: submitScript,
723 } = useProjectScriptSettings([
724 {
725 environmentId: selectedCheckout.environmentId,
726 settings: scriptSettings,
727 keybindings,
728 project: selectedCheckout,
729 },
730 ]);
731 const t3File = useT3ProjectFileState(
732 selectedCheckout.environmentId,
733 selectedCheckout.workspaceRoot,
734 );
735 // What the "Default" option resolves to while no override is set: the
736 // repo's t3.json value when present, otherwise the global setting.
737 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode;
738 const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
739 const importableScripts = useMemo(
740 () =>
741 t3File.scripts.filter(
742 (fileScript) =>
743 !scripts.some(
744 (script) =>
745 script.command === fileScript.command ||
746 script.name.toLowerCase() === fileScript.name.toLowerCase(),
747 ),
748 ),
749 [scripts, t3File.scripts],
750 );
751
752 const deleteScript = (scriptId: string) =>
753 void persistScripts(
754 (current) => current.filter((script) => script.id !== scriptId),
755 scriptId,
756 null,
757 );
758
759 const importFileScript = useCallback(
760 async (fileScript: T3ProjectFileScript) => {
761 const payload: NewProjectScriptInput = {
762 name: fileScript.name,
763 command: fileScript.command,
764 icon: fileScript.icon ?? "play",
765 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
766 keybinding: null,
767 previewUrl: fileScript.previewUrl ?? null,
768 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
769 };
770 const result = await submitScript(null, payload);
771 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
772 const error = squashAtomCommandFailure(result);
773 setEditorRequest({
774 scriptId: null,
775 initial: payload,
776 error: error instanceof Error ? error.message : "Failed to import action.",
777 });
778 }
779 },
780 [submitScript, setEditorRequest],
781 );
782
783 // ----- checkouts -----
784 const updateGroupingPreference = useCallback(
785 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
786 const overrideKey = deriveProjectGroupingOverrideKey(member);
787 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
788 if (selection === "inherit") {
789 delete nextOverrides[overrideKey];
790 } else {
791 nextOverrides[overrideKey] = selection;
792 }
793 updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides });
794 },
795 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings],
796 );
797294
798 const removeMembers = useCallback(295 const removeMembers = useCallback(
799 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {296 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
...@@ -884,26 +381,32 @@ function ProjectDetail({...@@ -884,26 +381,32 @@ function ProjectDetail({
884 ],381 ],
885 );382 );
886383
887 const selectedCheckoutGrouping =384 const checkoutChoices = (
888 projectGroupingSettings.sidebarProjectGroupingOverrides?.[385 <SettingsSection title="Checkouts">
889 deriveProjectGroupingOverrideKey(selectedCheckout)386 {group.memberProjects.map((member) => (
890 ] ?? "inherit";387 <SettingsRow
891 const checkoutLabel = (member: SidebarProjectGroupMember) => {388 key={member.physicalProjectKey}
892 const label = member.environmentLabel ?? "This machine";389 title={member.environmentLabel ?? "Environment"}
893 return group.memberProjects.some(390 description={member.workspaceRoot}
894 (other) =>391 control={
895 other.physicalProjectKey !== member.physicalProjectKey &&392 <Button
896 (other.environmentLabel ?? "This machine") === label,393 size="sm"
897 )394 variant="outline"
898 ? `${label} · ${member.workspaceRoot}`395 onClick={() => void removeMembers([member])}
899 : label;396 aria-label={`Remove checkout ${member.workspaceRoot}`}
900 };397 >
901 const selectedCheckoutLabel = checkoutLabel(selectedCheckout);398 Remove
399 </Button>
400 }
401 />
402 ))}
403 </SettingsSection>
404 );
902405
903 return (406 return (
904 <>407 <>
905 <SettingsPageContainer className="gap-6">408 <SettingsPageContainer className="gap-6">
906 <SettingsSection title="Project" hideTitle>409 <SettingsSection id="project-overview" title="Project" hideTitle>
907 <SettingsRow410 <SettingsRow
908 title="Name"411 title="Name"
909 description="The shared name for this project group in the sidebar and thread lists."412 description="The shared name for this project group in the sidebar and thread lists."
...@@ -938,7 +441,9 @@ function ProjectDetail({...@@ -938,7 +441,9 @@ function ProjectDetail({
938 : (faviconPath ?? "Automatic")441 : (faviconPath ?? "Automatic")
939 }442 }
940 resetAction={443 resetAction={
941 faviconPath !== null || projectIcon !== null ? (444 group.memberProjects.some(
445 (member) => member.faviconPath != null || member.projectIcon != null,
446 ) ? (
942 <SettingResetButton447 <SettingResetButton
943 label="project icon"448 label="project icon"
944 disabled={isSavingFavicon}449 disabled={isSavingFavicon}
...@@ -972,430 +477,9 @@ function ProjectDetail({...@@ -972,430 +477,9 @@ function ProjectDetail({
972 </div>477 </div>
973 }478 }
974 />479 />
975 <SettingsRow
976 title="Default merge method"
977 description="Pull requests in this project start with this method. It overrides the last method selected."
978 resetAction={
979 projectMergeMethod !== undefined ? (
980 <SettingResetButton
981 label="project merge method"
982 onClick={() => setProjectMergeMethod(null)}
983 />
984 ) : null
985 }
986 control={
987 <Select
988 value={projectMergeMethod ?? "inherit"}
989 onValueChange={(value) =>
990 setProjectMergeMethod(
991 value === "inherit" ? null : (value as PullRequestMergeMethod),
992 )
993 }
994 >
995 <SelectTrigger aria-label="Default pull request merge method">
996 <SelectValue>
997 {projectMergeMethod === undefined
998 ? "Last selected"
999 : PULL_REQUEST_MERGE_METHOD_LABELS[projectMergeMethod]}
1000 </SelectValue>
1001 </SelectTrigger>
1002 <SelectPopup align="end" alignItemWithTrigger={false}>
1003 <SelectItem value="inherit">Last selected</SelectItem>
1004 <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem>
1005 <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem>
1006 <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem>
1007 </SelectPopup>
1008 </Select>
1009 }
1010 />
1011 <SettingsRow
1012 title="Model"
1013 status={
1014 mixedModel
1015 ? "Mixed defaults or overrides. Choosing a model updates all selected checkouts."
1016 : storedSelection === null
1017 ? "Inherited"
1018 : "Overridden"
1019 }
1020 description={
1021 storedSelection === null
1022 ? "Inherited from machine defaults. New threads use the default model."
1023 : "Overridden for this project. Reset to use the default model."
1024 }
1025 resetAction={
1026 group.memberProjects.some((member) => member.defaultModelSelection !== null) ? (
1027 <SettingResetButton
1028 label="project default model"
1029 tooltip="Reset to inherited model"
1030 onClick={() => setDefaultModel(null)}
1031 />
1032 ) : null
1033 }
1034 control={
1035 resolvedSelection && activeEntry ? (
1036 <div className="flex flex-wrap items-center justify-end gap-1.5">
1037 <ProviderModelPicker
1038 activeInstanceId={resolvedSelection.instanceId}
1039 model={resolvedSelection.model}
1040 lockedProvider={null}
1041 instanceEntries={instanceEntries}
1042 modelOptionsByInstance={modelOptionsByInstance}
1043 triggerVariant="outline"
1044 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
1045 onOpenProviderSetup={(instanceId) => {
1046 void navigate({
1047 to: "/settings/providers",
1048 search: { environmentId: representative.environmentId, instanceId },
1049 });
1050 }}
1051 onInstanceModelChange={(instanceId, model) => {
1052 setDefaultModel(createModelSelection(instanceId, model));
1053 }}
1054 />
1055 <TraitsPicker
1056 provider={activeEntry.driverKind as ProviderDriverKind}
1057 models={activeEntry.models}
1058 model={resolvedSelection.model}
1059 prompt=""
1060 onPromptChange={() => {}}
1061 modelOptions={resolvedSelection.options ?? []}
1062 allowPromptInjectedEffort={false}
1063 planModeEnabled={projectSettings.planModeEnabled}
1064 triggerVariant="outline"
1065 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
1066 onModelOptionsChange={(nextOptions) => {
1067 setDefaultModel(
1068 createModelSelection(
1069 resolvedSelection.instanceId,
1070 resolvedSelection.model,
1071 nextOptions,
1072 ),
1073 );
1074 }}
1075 />
1076 </div>
1077 ) : (
1078 <span className="text-sm text-muted-foreground">No providers available</span>
1079 )
1080 }
1081 />
1082 <SettingsRow
1083 title="Workspace"
1084 status={
1085 mixedWorkspace
1086 ? "Mixed overrides. Choosing a workspace updates all selected checkouts."
1087 : storedEnvMode === null
1088 ? "Inherited"
1089 : "Overridden"
1090 }
1091 description={
1092 storedEnvMode === null
1093 ? "Inherited from t3.json or machine defaults."
1094 : "Overridden for this project. Reset to inherit its workspace default."
1095 }
1096 resetAction={
1097 group.memberProjects.some((member) => member.defaultThreadEnvMode !== null) ? (
1098 <SettingResetButton
1099 label="project workspace default"
1100 tooltip="Reset to inherited workspace"
1101 onClick={() => setDefaultThreadEnvMode(null)}
1102 />
1103 ) : null
1104 }
1105 control={
1106 <Select
1107 value={storedEnvMode ?? "inherit"}
1108 onValueChange={(value) => {
1109 if (value === "worktree" || value === "local") {
1110 setDefaultThreadEnvMode(value);
1111 } else if (value === "inherit") {
1112 setDefaultThreadEnvMode(null);
1113 }
1114 }}
1115 >
1116 <SelectTrigger size="sm" aria-label="New-thread workspace">
1117 <SelectValue>
1118 {storedEnvMode === null
1119 ? group.memberProjects.length > 1
1120 ? "Default (per checkout)"
1121 : `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
1122 : resolveEnvModeLabel(storedEnvMode)}
1123 </SelectValue>
1124 </SelectTrigger>
1125 <SelectPopup align="end" alignItemWithTrigger={false}>
1126 <SelectItem value="inherit">
1127 {group.memberProjects.length > 1
1128 ? "Default (each checkout's t3.json or global setting)"
1129 : `Default (${inheritedEnvModeSource}: ${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`}
1130 </SelectItem>
1131 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
1132 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
1133 </SelectPopup>
1134 </Select>
1135 }
1136 />
1137 <SettingsRow
1138 title="Automatically pull"
1139 description="Keeps the default branch current in the background when the checkout has no local changes or commits."
1140 status={
1141 mixedAutoPull
1142 ? "Mixed"
1143 : autoPullOverridden
1144 ? "Overridden"
1145 : `Inherited (${autoPull ? "on" : "off"})`
1146 }
1147 resetAction={
1148 autoPullOverridden ? (
1149 <SettingResetButton
1150 label="automatic pull"
1151 tooltip="Reset to inherited automatic pull setting"
1152 disabled={savingBrowserAccess}
1153 onClick={() => void setAutoPull(undefined)}
1154 />
1155 ) : null
1156 }
1157 control={
1158 <Switch
1159 checked={autoPull}
1160 disabled={savingBrowserAccess}
1161 aria-label="Automatically pull the default branch"
1162 onCheckedChange={(enabled) => void setAutoPull(enabled)}
1163 />
1164 }
1165 />
1166 <SettingsRow
1167 title="Agent browser access"
1168 description={
1169 browserMixed
1170 ? "Mixed defaults or overrides across selected checkouts."
1171 : browserOverride === undefined
1172 ? "Inherited from machine defaults. Controls agent access to the preview browser."
1173 : "Overridden for this project. Applies when the agent session next starts."
1174 }
1175 resetAction={
1176 browserOverrides.some((value) => value !== undefined) ? (
1177 <SettingResetButton
1178 label="project browser access"
1179 tooltip="Reset to inherited browser access"
1180 disabled={savingBrowserAccess}
1181 onClick={() => void setBrowserAccess(undefined)}
1182 />
1183 ) : null
1184 }
1185 control={
1186 <Select
1187 value={
1188 browserMixed
1189 ? "mixed"
1190 : browserOverride === undefined
1191 ? "inherit"
1192 : browserOverride
1193 ? "enabled"
1194 : "disabled"
1195 }
1196 disabled={savingBrowserAccess}
1197 onValueChange={(value) => {
1198 if (value === "inherit") void setBrowserAccess(undefined);
1199 else if (value === "enabled" || value === "disabled")
1200 void setBrowserAccess(value === "enabled");
1201 }}
1202 >
1203 <SelectTrigger size="sm" aria-label="Project agent browser access">
1204 <SelectValue>
1205 {browserMixed
1206 ? "Mixed"
1207 : browserOverride === undefined
1208 ? `Inherit (${projectSettings.enableAgentBrowserAccess ? "on" : "off"})`
1209 : browserOverride
1210 ? "On"
1211 : "Off"}
1212 </SelectValue>
1213 </SelectTrigger>
1214 <SelectPopup align="end" alignItemWithTrigger={false}>
1215 <SelectItem value="inherit">Inherit defaults</SelectItem>
1216 <SelectItem value="enabled">On</SelectItem>
1217 <SelectItem value="disabled">Off</SelectItem>
1218 </SelectPopup>
1219 </Select>
1220 }
1221 />
1222 </SettingsSection>480 </SettingsSection>
1223481 <ProjectActionsSettings />
1224 <SettingsSection title="Checkout">482 {hasMultipleCheckouts ? checkoutChoices : null}
1225 {hasMultipleCheckouts ? (
1226 <SettingsRow
1227 title="Checkout"
1228 description="Actions and grouping belong to this checkout."
1229 control={
1230 <Select
1231 value={selectedCheckout.physicalProjectKey}
1232 onValueChange={(value) => {
1233 if (value) setSelectedCheckoutKey(value);
1234 }}
1235 >
1236 <SelectTrigger size="sm" aria-label="Checkout">
1237 <SelectValue className="max-w-96 truncate">{selectedCheckoutLabel}</SelectValue>
1238 </SelectTrigger>
1239 <SelectPopup align="end" alignItemWithTrigger={false}>
1240 {group.memberProjects.map((member) => (
1241 <SelectItem key={member.physicalProjectKey} value={member.physicalProjectKey}>
1242 <span className="max-w-96 whitespace-normal break-all">
1243 {checkoutLabel(member)}
1244 </span>
1245 </SelectItem>
1246 ))}
1247 </SelectPopup>
1248 </Select>
1249 }
1250 />
1251 ) : null}
1252 <SettingsRow
1253 title="Project grouping"
1254 description="How this checkout joins project groups in the sidebar. Changing it can move you to a different project group."
1255 resetAction={
1256 selectedCheckoutGrouping !== "inherit" ? (
1257 <SettingResetButton
1258 label="project grouping"
1259 tooltip="Reset to inherited project grouping"
1260 onClick={() => updateGroupingPreference(selectedCheckout, "inherit")}
1261 />
1262 ) : null
1263 }
1264 control={
1265 <Select
1266 value={selectedCheckoutGrouping}
1267 onValueChange={(value) => {
1268 if (
1269 value === "inherit" ||
1270 value === "repository" ||
1271 value === "repository_path" ||
1272 value === "separate"
1273 ) {
1274 updateGroupingPreference(selectedCheckout, value);
1275 }
1276 }}
1277 >
1278 <SelectTrigger size="sm" aria-label={`Grouping rule for ${selectedCheckoutLabel}`}>
1279 <SelectValue>
1280 {selectedCheckoutGrouping === "inherit"
1281 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
1282 : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]}
1283 </SelectValue>
1284 </SelectTrigger>
1285 <SelectPopup align="end" alignItemWithTrigger={false}>
1286 <SelectItem hideIndicator value="inherit">
1287 Use global default
1288 </SelectItem>
1289 <SelectItem hideIndicator value="repository">
1290 {PROJECT_GROUPING_MODE_LABELS.repository}
1291 </SelectItem>
1292 <SelectItem hideIndicator value="repository_path">
1293 {PROJECT_GROUPING_MODE_LABELS.repository_path}
1294 </SelectItem>
1295 <SelectItem hideIndicator value="separate">
1296 {PROJECT_GROUPING_MODE_LABELS.separate}
1297 </SelectItem>
1298 </SelectPopup>
1299 </Select>
1300 }
1301 />
1302 {group.memberProjects.length > 1 ? (
1303 <SettingsRow
1304 title="Remove checkout"
1305 description="Removes this checkout and its threads from the project group. Files on disk are not touched."
1306 control={
1307 <Button
1308 size="sm"
1309 variant="destructive-outline"
1310 onClick={() => void removeMembers([selectedCheckout])}
1311 >
1312 <Trash2Icon className="size-3.5" />
1313 Remove checkout
1314 </Button>
1315 }
1316 />
1317 ) : null}
1318 <div className="flex min-h-8 flex-col items-start gap-3 px-3 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-4">
1319 <div className="min-w-0">
1320 <h3 className="text-base font-semibold text-foreground">Actions</h3>
1321 <p className="text-pretty text-sm text-muted-foreground">
1322 {scriptsInherited
1323 ? "Inherited from machine defaults."
1324 : `Overridden for ${selectedCheckoutLabel}.`}
1325 </p>
1326 </div>
1327 <div className="flex w-full flex-wrap gap-1.5 sm:w-auto sm:shrink-0 sm:justify-end">
1328 {!scriptsInherited ? (
1329 <SettingResetButton
1330 label="project actions"
1331 tooltip="Reset to inherited actions"
1332 disabled={isSavingScripts}
1333 onClick={() => void persistScripts(() => null)}
1334 />
1335 ) : null}
1336 {importableScripts.length > 0 ? (
1337 <Menu>
1338 <MenuTrigger
1339 render={
1340 <Button size="xs" variant="ghost" disabled={isSavingScripts} type="button" />
1341 }
1342 >
1343 Import scripts
1344 <ChevronDownIcon className="size-3.5" />
1345 </MenuTrigger>
1346 <MenuPopup align="end" className="w-72">
1347 <MenuGroup>
1348 <MenuGroupLabel>Import from t3.json</MenuGroupLabel>
1349 <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground">
1350 Add actions declared by this checkout without editing them first.
1351 </p>
1352 </MenuGroup>
1353 <MenuSeparator />
1354 {importableScripts.map((fileScript) => (
1355 <MenuItem
1356 key={`${fileScript.name} ${fileScript.command}`}
1357 onClick={() => void importFileScript(fileScript)}
1358 >
1359 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" />
1360 <div className="min-w-0 flex-1">
1361 <div className="truncate font-medium">{fileScript.name}</div>
1362 <div className="truncate font-mono text-muted-foreground">
1363 {fileScript.command}
1364 </div>
1365 </div>
1366 </MenuItem>
1367 ))}
1368 </MenuPopup>
1369 </Menu>
1370 ) : null}
1371 <Button
1372 size="xs"
1373 variant="outline"
1374 disabled={isSavingScripts}
1375 onClick={() =>
1376 setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })
1377 }
1378 >
1379 <PlusIcon className="size-3.5" />
1380 Add action
1381 </Button>
1382 </div>
1383 </div>
1384 <ProjectActionsList
1385 scripts={scripts}
1386 keybindings={keybindings}
1387 disabled={isSavingScripts}
1388 onEdit={(script) => setEditorRequest(editorRequestForScript(script, keybindings))}
1389 />
1390 {t3File.status === "invalid" ? (
1391 <SettingsRow
1392 title="t3.json is invalid"
1393 description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values."
1394 className="text-warning"
1395 />
1396 ) : null}
1397 </SettingsSection>
1398
1399 <SettingsSection title="Danger">483 <SettingsSection title="Danger">
1400 <SettingsRow484 <SettingsRow
1401 title={485 title={
...@@ -1430,13 +514,6 @@ function ProjectDetail({...@@ -1430,13 +514,6 @@ function ProjectDetail({
1430 </SettingsSection>514 </SettingsSection>
1431 </SettingsPageContainer>515 </SettingsPageContainer>
1432516
1433 <ProjectScriptEditorDialog
1434 request={editorRequest}
1435 scripts={scripts}
1436 onSubmit={submitScript}
1437 onDelete={deleteScript}
1438 onClose={() => setEditorRequest(null)}
1439 />
1440 <ProjectFaviconPickerDialog517 <ProjectFaviconPickerDialog
1441 key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}518 key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}
1442 cwd={representative.workspaceRoot}519 cwd={representative.workspaceRoot}
apps/web/src/components/settings/ProjectsSettings.tsx+10-30
...@@ -1,21 +1,12 @@...@@ -1,21 +1,12 @@
1import { WorkspacePageContainer } from "../WorkspacePageContainer";1import { EnvironmentId } from "@t3tools/contracts";
2import { useEnvironments } from "../../state/environments";2
3import { ProjectSettingsPanel } from "./ProjectSettingsPanel";3import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
4import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";4import { useSettingsScope } from "./SettingsScopeContext";
5import { SettingsScopeSelects } from "./SettingsScopeSelects";5import { SettingsScopeNotice } from "./SettingsScopeNotice";
6import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
7import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
86
9export function ProjectsSettings({7/** Project identity and checkout management for the selected project. */
10 value,8export function ProjectsSettings() {
11 onScopeChange,9 const { search: value, scope } = useSettingsScope();
12}: {
13 value: SettingsScopeSearch;
14 onScopeChange: (scope: SettingsScopeSearch) => void;
15}) {
16 const groups = useSettingsProjectGroups();
17 const { environments } = useEnvironments();
18 const scope = resolveSettingsScope(value, groups, environments);
19 // The panel follows remembered members when grouping replaces a project key.10 // The panel follows remembered members when grouping replaces a project key.
20 const projectScope =11 const projectScope =
21 scope.kind === "project" ||12 scope.kind === "project" ||
...@@ -24,16 +15,6 @@ export function ProjectsSettings({...@@ -24,16 +15,6 @@ export function ProjectsSettings({
24 (scope.reason === "project-missing" || scope.reason === "checkout-missing"));15 (scope.reason === "project-missing" || scope.reason === "checkout-missing"));
25 return (16 return (
26 <div className="flex min-h-0 flex-1 flex-col">17 <div className="flex min-h-0 flex-1 flex-col">
27 <div className="scrollbar-gutter-both shrink-0 overflow-y-auto">
28 <WorkspacePageContainer className="items-end pb-0">
29 <SettingsScopeSelects
30 value={value}
31 groups={groups}
32 environments={environments}
33 onChange={onScopeChange}
34 />
35 </WorkspacePageContainer>
36 </div>
37 {value.project && projectScope ? (18 {value.project && projectScope ? (
38 <ProjectSettingsPanel19 <ProjectSettingsPanel
39 projectKey={value.project}20 projectKey={value.project}
...@@ -43,11 +24,10 @@ export function ProjectsSettings({...@@ -43,11 +24,10 @@ export function ProjectsSettings({
43 ) : scope.kind === "unavailable" ? (24 ) : scope.kind === "unavailable" ? (
44 <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>25 <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
45 ) : (26 ) : (
46 <ProjectDefaultsSettings27 <SettingsScopeNotice target="project">
47 environmentId={scope.kind === "environment" ? scope.environmentId : null}28 Choose a project to manage its name, icon, checkouts and actions.
48 />29 </SettingsScopeNotice>
49 )}30 )}
50 </div>31 </div>
51 );32 );
52}33}
53import { EnvironmentId } from "@t3tools/contracts";
apps/web/src/components/settings/ProviderInstanceCard.tsx+4
...@@ -896,6 +896,10 @@ export function ProviderInstanceCard({...@@ -896,6 +896,10 @@ export function ProviderInstanceCard({
896 className={readOnly ? "opacity-50 select-none" : undefined}896 className={readOnly ? "opacity-50 select-none" : undefined}
897 >897 >
898 <div className="px-3 py-3 sm:px-4">898 <div className="px-3 py-3 sm:px-4">
899 <p className="mb-3 text-xs text-muted-foreground">
900 Favorites, visibility, and ordering are saved on this device. Custom models are saved
901 on the selected environment.
902 </p>
899 <ProviderModelsSection903 <ProviderModelsSection
900 instanceId={instanceId}904 instanceId={instanceId}
901 driverKind={driverKind}905 driverKind={driverKind}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+28-5
...@@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({...@@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({
29 readEnvironmentIds: [] as EnvironmentId[],29 readEnvironmentIds: [] as EnvironmentId[],
30 updateEnvironmentIds: [] as EnvironmentId[],30 updateEnvironmentIds: [] as EnvironmentId[],
31 updateSettings: vi.fn(),31 updateSettings: vi.fn(),
32 updateClientSettings: vi.fn(),
32}));33}));
3334
34const settingsSearchState = vi.hoisted(() => ({35const settingsSearchState = vi.hoisted(() => ({
...@@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({...@@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({
81}));82}));
8283
83vi.mock("../../hooks/useSettings", () => ({84vi.mock("../../hooks/useSettings", () => ({
85 useUpdateClientSettings: () => settingsState.updateClientSettings,
84 useEnvironmentSettings: (environmentId: EnvironmentId) => {86 useEnvironmentSettings: (environmentId: EnvironmentId) => {
85 settingsState.readEnvironmentIds.push(environmentId);87 settingsState.readEnvironmentIds.push(environmentId);
86 return settingsState.value;88 return settingsState.value;
87 },89 },
88 useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => {90 useUpdateEnvironmentSettings: () => settingsState.updateSettings,
89 settingsState.updateEnvironmentIds.push(environmentId);
90 return settingsState.updateSettings;
91 },
92}));91}));
9392
94vi.mock("../../environments/primary", () => ({93vi.mock("../../environments/primary", () => ({
...@@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => {
177 settingsState.readEnvironmentIds = [];176 settingsState.readEnvironmentIds = [];
178 settingsState.updateEnvironmentIds = [];177 settingsState.updateEnvironmentIds = [];
179 settingsState.updateSettings.mockReset();178 settingsState.updateSettings.mockReset();
179 settingsState.updateClientSettings.mockReset();
180 settingsSearchState.targetId = null;180 settingsSearchState.targetId = null;
181 settingsSearchState.effects = [];181 settingsSearchState.effects = [];
182 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });182 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
...@@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => {
186 it("coalesces a nullable provider snapshot before rendering array-backed UI", () => {186 it("coalesces a nullable provider snapshot before rendering array-backed UI", () => {
187 expect(() => renderPanel()).not.toThrow();187 expect(() => renderPanel()).not.toThrow();
188 expect(settingsState.readEnvironmentIds).toEqual([environmentId]);188 expect(settingsState.readEnvironmentIds).toEqual([environmentId]);
189 expect(settingsState.updateEnvironmentIds).toEqual([environmentId]);
190 });189 });
191190
192 it("routes refresh and provider update commands to the selected environment", async () => {191 it("routes refresh and provider update commands to the selected environment", async () => {
...@@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => {
230 expect(editor?.props.instanceId).toBe(customId);229 expect(editor?.props.instanceId).toBe(customId);
231 });230 });
232231
232 it.each([
233 ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }],
234 [
235 "onHiddenModelsChange",
236 { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } },
237 ],
238 [
239 "onModelOrderChange",
240 { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } },
241 ],
242 ])("saves %s on this device without changing the selected server", (action, expected) => {
243 atoms.providers = [provider()];
244 const panel = renderPanel();
245 const editor = visitElements(
246 panel,
247 (element) => element.props.instanceId === codexId && element.props.mode === "editor",
248 );
249 expect(editor).not.toBeNull();
250 if (!editor) throw new Error("Provider editor was not rendered");
251 (editor.props[action] as (models: string[]) => void)(["chosen"]);
252 expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected);
253 expect(settingsState.updateSettings).not.toHaveBeenCalled();
254 });
255
233 it("does not substitute another account when the requested instance was removed", () => {256 it("does not substitute another account when the requested instance was removed", () => {
234 atoms.providers = [provider()];257 atoms.providers = [provider()];
235 const panel = renderPanel({ targetInstanceId: customId });258 const panel = renderPanel({ targetInstanceId: customId });
apps/web/src/components/settings/ProviderSettingsPanel.tsx+23-8
...@@ -31,7 +31,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro...@@ -31,7 +31,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
31import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";31import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
32import { isElectron } from "../../env";32import { isElectron } from "../../env";
33import { usePrimarySessionState } from "../../environments/primary";33import { usePrimarySessionState } from "../../environments/primary";
34import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";34import {
35 useEnvironmentSettings,
36 useUpdateClientSettings,
37 useUpdateEnvironmentSettings,
38} from "../../hooks/useSettings";
35import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";39import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
36import { cn } from "../../lib/utils";40import { cn } from "../../lib/utils";
37import { resolveAppModelSelectionState } from "../../modelSelection";41import { resolveAppModelSelectionState } from "../../modelSelection";
...@@ -262,6 +266,7 @@ function EnvironmentUnavailablePlaceholder({...@@ -262,6 +266,7 @@ function EnvironmentUnavailablePlaceholder({
262interface ProviderSettingsTarget {266interface ProviderSettingsTarget {
263 readonly environmentId?: EnvironmentId;267 readonly environmentId?: EnvironmentId;
264 readonly instanceId?: ProviderInstanceId;268 readonly instanceId?: ProviderInstanceId;
269 readonly scoped?: boolean;
265}270}
266271
267export function ProviderSettingsPanel(target: ProviderSettingsTarget) {272export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
...@@ -293,9 +298,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -293,9 +298,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
293 target.environmentId !== undefined &&298 target.environmentId !== undefined &&
294 selectedEnvironmentId === target.environmentId &&299 selectedEnvironmentId === target.environmentId &&
295 !options.some((environment) => environment.environmentId === target.environmentId);300 !options.some((environment) => environment.environmentId === target.environmentId);
296 const effectiveEnvironmentId = targetEnvironmentMissing301 const effectiveEnvironmentId =
297 ? target.environmentId302 target.scoped || targetEnvironmentMissing
298 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);303 ? target.environmentId
304 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);
299 const selectedEnvironment =305 const selectedEnvironment =
300 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;306 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
301 const selectedEnvironmentCanRenderSettings =307 const selectedEnvironmentCanRenderSettings =
...@@ -312,6 +318,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -312,6 +318,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
312 )?.environmentId;318 )?.environmentId;
313 useEffect(() => {319 useEffect(() => {
314 if (320 if (
321 !target.scoped &&
315 (searchTargetId === searchableSetting("provider-health-check-interval").id ||322 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
316 searchTargetId === searchableSetting("usage-providers").id) &&323 searchTargetId === searchableSetting("usage-providers").id) &&
317 !selectedEnvironmentCanRenderSettings &&324 !selectedEnvironmentCanRenderSettings &&
...@@ -319,11 +326,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -319,11 +326,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
319 ) {326 ) {
320 setSelectedEnvironmentId(searchableEnvironmentId);327 setSelectedEnvironmentId(searchableEnvironmentId);
321 }328 }
322 }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]);329 }, [
330 searchTargetId,
331 searchableEnvironmentId,
332 selectedEnvironmentCanRenderSettings,
333 target.scoped,
334 ]);
323 const onlyPrimaryDevice =335 const onlyPrimaryDevice =
324 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";336 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
325 const deviceTabs =337 const deviceTabs =
326 !onlyPrimaryDevice && options.length > 0 ? (338 !target.scoped && !onlyPrimaryDevice && options.length > 0 ? (
327 <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 flex-1 rounded-none">339 <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 flex-1 rounded-none">
328 <ToggleGroup340 <ToggleGroup
329 aria-label="Devices"341 aria-label="Devices"
...@@ -559,7 +571,10 @@ export function EnvironmentProviderSettings({...@@ -559,7 +571,10 @@ export function EnvironmentProviderSettings({
559 readonly readOnly?: boolean;571 readonly readOnly?: boolean;
560}) {572}) {
561 const settings = useEnvironmentSettings(environmentId);573 const settings = useEnvironmentSettings(environmentId);
574 // Provider instances hold per-machine credentials and binaries, so this
575 // page always edits exactly the environment it displays.
562 const updateSettings = useUpdateEnvironmentSettings(environmentId);576 const updateSettings = useUpdateEnvironmentSettings(environmentId);
577 const updateClientSettings = useUpdateClientSettings();
563 const serverProviders =578 const serverProviders =
564 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;579 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
565 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {580 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
...@@ -815,7 +830,7 @@ export function EnvironmentProviderSettings({...@@ -815,7 +830,7 @@ export function EnvironmentProviderSettings({
815 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];830 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
816 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];831 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
817 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);832 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
818 updateSettings({833 updateClientSettings({
819 providerModelPreferences:834 providerModelPreferences:
820 hiddenModels.length === 0 && modelOrder.length === 0835 hiddenModels.length === 0 && modelOrder.length === 0
821 ? rest836 ? rest
...@@ -841,7 +856,7 @@ export function EnvironmentProviderSettings({...@@ -841,7 +856,7 @@ export function EnvironmentProviderSettings({
841 }),856 }),
842 ),857 ),
843 ];858 ];
844 updateSettings({859 updateClientSettings({
845 favorites: [860 favorites: [
846 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),861 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
847 ...favoriteModels.map((model) => ({ provider: instanceId, model })),862 ...favoriteModels.map((model) => ({ provider: instanceId, model })),
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+26-15
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13} from "lucide-react";13} from "lucide-react";
14import type {14import type {
15 BackgroundBooleanState,15 BackgroundBooleanState,
16 EnvironmentId,
16 ResourceAttributionEntry,17 ResourceAttributionEntry,
17 ResourceTelemetryAggregate,18 ResourceTelemetryAggregate,
18 ResourceTelemetryHistoryBucket,19 ResourceTelemetryHistoryBucket,
...@@ -26,7 +27,7 @@ import type {...@@ -26,7 +27,7 @@ import type {
26} from "@t3tools/contracts";27} from "@t3tools/contracts";
27import * as DateTime from "effect/DateTime";28import * as DateTime from "effect/DateTime";
28import * as Option from "effect/Option";29import * as Option from "effect/Option";
29import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";30import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
30import {31import {
31 isAtomCommandInterrupted,32 isAtomCommandInterrupted,
32 squashAtomCommandFailure,33 squashAtomCommandFailure,
...@@ -38,7 +39,6 @@ import {...@@ -38,7 +39,6 @@ import {
38} from "../../lib/resourceTelemetryState";39} from "../../lib/resourceTelemetryState";
39import { cn } from "../../lib/utils";40import { cn } from "../../lib/utils";
40import { ensureLocalApi } from "../../localApi";41import { ensureLocalApi } from "../../localApi";
41import { usePrimaryEnvironment } from "../../state/environments";
42import { serverEnvironment } from "../../state/server";42import { serverEnvironment } from "../../state/server";
43import { useAtomCommand } from "../../state/use-atom-command";43import { useAtomCommand } from "../../state/use-atom-command";
44import { formatRelativeTime } from "../../timestampFormat";44import { formatRelativeTime } from "../../timestampFormat";
...@@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttribut...@@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttribut
831 );831 );
832}832}
833833
834export function ResourceTelemetryDiagnostics() {834export function ResourceTelemetryDiagnostics({
835 environmentId,
836}: {
837 environmentId: EnvironmentId | null;
838}) {
835 const [windowMs, setWindowMs] = useState(15 * 60_000);839 const [windowMs, setWindowMs] = useState(15 * 60_000);
836 const selectedWindow =840 const selectedWindow =
837 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];841 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
838 const telemetry = useResourceTelemetry();842 const telemetry = useResourceTelemetry(environmentId);
839 const retryTelemetry = telemetry.retry;843 const retryTelemetry = telemetry.retry;
840 const history = useResourceTelemetryHistory({844 const history = useResourceTelemetryHistory(
841 windowMs: selectedWindow.windowMs,845 {
842 bucketMs: selectedWindow.bucketMs,846 windowMs: selectedWindow.windowMs,
843 });847 bucketMs: selectedWindow.bucketMs,
844 const primaryEnvironment = usePrimaryEnvironment();848 },
849 environmentId,
850 );
845 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {851 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
846 reportFailure: false,852 reportFailure: false,
847 });853 });
848 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());854 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());
849 const signalingKeysRef = useRef<ReadonlySet<string>>(new Set());855 const signalingKeysRef = useRef<ReadonlySet<string>>(new Set());
850 signalingKeysRef.current = signalingKeys;856 const environmentIdRef = useRef(environmentId);
851 const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId);857 useEffect(() => {
852 primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId;858 environmentIdRef.current = environmentId;
859 return () => {
860 environmentIdRef.current = null;
861 };
862 }, [environmentId]);
853 const [isRetrying, setIsRetrying] = useState(false);863 const [isRetrying, setIsRetrying] = useState(false);
854 const snapshot = telemetry.data;864 const snapshot = telemetry.data;
855 const allT3 = snapshot?.groups.allT3;865 const allT3 = snapshot?.groups.allT3;
856866
857 const signalProcess = useCallback(867 const signalProcess = useCallback(
858 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {868 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
869 const targetEnvironmentId = environmentIdRef.current;
870 if (targetEnvironmentId === null) return;
859 const identityKey = processIdentityKey(process);871 const identityKey = processIdentityKey(process);
860 if (signalingKeysRef.current.has(identityKey)) return;872 if (signalingKeysRef.current.has(identityKey)) return;
861 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);873 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
...@@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() {...@@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() {
889 return;901 return;
890 }902 }
891 }903 }
892 const environmentId = primaryEnvironmentIdRef.current;904 if (environmentIdRef.current !== targetEnvironmentId) {
893 if (environmentId === undefined) {
894 clearSignaling();905 clearSignaling();
895 return;906 return;
896 }907 }
897 void signalServerProcess({908 void signalServerProcess({
898 environmentId,909 environmentId: targetEnvironmentId,
899 input: {910 input: {
900 pid: process.identity.pid,911 pid: process.identity.pid,
901 startTimeMs: process.identity.startTimeMs,912 startTimeMs: process.identity.startTimeMs,
apps/web/src/components/settings/ScopedSwitch.tsx created+19
...@@ -0,0 +1,19 @@
1import type { ServerSettings } from "@t3tools/contracts";
2import type { ComponentProps } from "react";
3
4import { Switch } from "../ui/switch";
5import { useScopedSettingsMixed } from "./useScopedSettings";
6
7/**
8 * A switch for a server setting that renders the mixed state when the
9 * selected targets disagree on `settingKeys`. Clicking a mixed switch turns
10 * it on everywhere, the macOS mixed-checkbox convention.
11 */
12export function ScopedSwitch({
13 settingKeys,
14 checked,
15 ...props
16}: ComponentProps<typeof Switch> & { settingKeys: readonly (keyof ServerSettings)[] }) {
17 const mixed = useScopedSettingsMixed(settingKeys);
18 return <Switch {...props} mixed={mixed} checked={mixed ? false : checked} />;
19}
apps/web/src/components/settings/SettingInheritance.test.ts created+57
...@@ -0,0 +1,57 @@
1import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3import { describe, expect, it } from "vite-plus/test";
4
5import { settingInheritanceLayers } from "./SettingInheritance";
6
7const environmentId = EnvironmentId.make("laptop");
8const projectId = ProjectId.make("project");
9
10describe("settingInheritanceLayers", () => {
11 it("marks the built-in default effective when nothing is set", () => {
12 const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null);
13 const layers = settingInheritanceLayers(
14 { environmentId, label: "Laptop", projectId: null, ...resolved },
15 DEFAULT_SERVER_SETTINGS,
16 "defaultAutoPull",
17 );
18 expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
19 ["Laptop", "Inherits", false],
20 ["Default", "Off", true],
21 ]);
22 });
23
24 it("walks project override, environment value, then built-in default", () => {
25 const settings = {
26 ...DEFAULT_SERVER_SETTINGS,
27 defaultAutoPull: true,
28 projectSettingsOverrides: { [projectId]: { defaultAutoPull: false } },
29 };
30 const resolved = resolveProjectSettings(settings, projectId);
31 const layers = settingInheritanceLayers(
32 { environmentId, label: "Laptop", projectId, ...resolved },
33 settings,
34 "defaultAutoPull",
35 );
36 expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([
37 ["Project", "Off", true],
38 ["Laptop", "On", false],
39 ["Default", "Off", false],
40 ]);
41 const inherited = settingInheritanceLayers(
42 {
43 environmentId,
44 label: "Laptop",
45 projectId,
46 ...resolveProjectSettings(settings, ProjectId.make("other")),
47 },
48 settings,
49 "defaultAutoPull",
50 );
51 expect(inherited.map((layer) => [layer.value, layer.effective])).toEqual([
52 ["Inherits", false],
53 ["On", true],
54 ["Off", false],
55 ]);
56 });
57});
apps/web/src/components/settings/SettingInheritance.tsx created+303
...@@ -0,0 +1,303 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 resolveEnvironmentMachineKind,
4 type ServerSettings,
5} from "@t3tools/contracts";
6import { CheckIcon, LayersIcon } from "lucide-react";
7import * as Equal from "effect/Equal";
8
9import { cn } from "../../lib/utils";
10import type { EnvironmentPresentation } from "../../state/environments";
11import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
12import { resolveEnvModeLabel } from "../BranchToolbar.logic";
13import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
14import { Button } from "../ui/button";
15import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings";
18import { isProjectScopedSettingKey } from "./scopedSettings";
19
20interface InheritanceLayer {
21 readonly key: "project" | "environment" | "built-in";
22 readonly label: string;
23 readonly value: string;
24 readonly effective: boolean;
25 readonly set: boolean;
26}
27
28const WRITING_STYLE_LABELS: Record<string, string> = {
29 repo_conventions: "Repository conventions",
30 conventional_commits: "Conventional Commits",
31 custom: "Custom instructions",
32};
33
34/** Human labels for the values the chain can show; falls back to a type summary. */
35function formatValue(key: keyof ServerSettings, value: unknown): string {
36 if (value === null || value === undefined) {
37 return key === "pullRequestMergeMethod"
38 ? "Last selected"
39 : key === "sidebarAutoSettleAfterDays"
40 ? "Never"
41 : key === "defaultModelSelection"
42 ? "Automatic"
43 : key === "sourceControlWriterModelSelection"
44 ? "Text generation model"
45 : "Not set";
46 }
47 if (typeof value === "boolean") return value ? "On" : "Off";
48 if (typeof value === "number") {
49 return key === "sidebarAutoSettleAfterDays"
50 ? `${value} ${value === 1 ? "day" : "days"}`
51 : String(value);
52 }
53 if (typeof value === "string") {
54 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
55 return resolveEnvModeLabel(value);
56 }
57 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
58 return PULL_REQUEST_MERGE_METHOD_LABELS[
59 value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
60 ];
61 }
62 return value === "" ? "Empty" : value;
63 }
64 if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`;
65 if (typeof value === "object") {
66 if ("model" in value && typeof value.model === "string") return value.model;
67 if ("mode" in value && typeof value.mode === "string") {
68 return WRITING_STYLE_LABELS[value.mode] ?? value.mode;
69 }
70 }
71 return "Custom";
72}
73
74/**
75 * The layers a setting resolves through for one target, top-down: the
76 * project override when the target is a project, the environment's value,
77 * and the built-in default. The first layer that is set wins.
78 */
79export function settingInheritanceLayers(
80 target: ScopedSettingsTarget,
81 environmentSettings: ServerSettings,
82 key: keyof ServerSettings,
83): readonly InheritanceLayer[] {
84 const builtIn = DEFAULT_SERVER_SETTINGS[key];
85 const environmentValue = environmentSettings[key];
86 const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
87 const environmentSet = !Equal.equals(environmentValue, builtIn);
88 const layers: InheritanceLayer[] = [];
89 if (target.projectId !== null && isProjectScopedSettingKey(key)) {
90 layers.push({
91 key: "project",
92 label: "Project",
93 value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits",
94 effective: projectSource === "project",
95 set: projectSource === "project",
96 });
97 }
98 layers.push({
99 key: "environment",
100 label: target.label,
101 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
102 effective: projectSource !== "project" && environmentSet,
103 set: environmentSet,
104 });
105 layers.push({
106 key: "built-in",
107 label: "Default",
108 value: formatValue(key, builtIn),
109 effective: projectSource !== "project" && !environmentSet,
110 set: true,
111 });
112 return layers;
113}
114
115export type SettingInheritanceState =
116 | "default"
117 | "environment"
118 | "inherited"
119 | "overridden"
120 | "mixed";
121
122/**
123 * A small indicator beside a row's title that opens a top-down view of where
124 * the setting's value comes from on each selected target. It sits inline so
125 * narrowing to a project does not add a caption line to every row.
126 */
127export interface SettingOverridingProject extends ProjectOverrideEntry {
128 readonly label: string;
129 /** Jumps the breadcrumb to this project so its override can be edited. */
130 readonly open: () => void;
131}
132
133export function SettingInheritance({
134 state,
135 summary,
136 targets,
137 environments,
138 keys,
139 overridingProjects = [],
140 onClearOverrides,
141}: {
142 state: SettingInheritanceState;
143 summary: string;
144 targets: readonly ScopedSettingsTarget[];
145 environments: readonly Pick<EnvironmentPresentation, "environmentId" | "serverConfig">[];
146 keys: readonly (keyof ServerSettings)[];
147 /** At environment scope: projects whose own value hides the environment's. */
148 overridingProjects?: readonly SettingOverridingProject[];
149 onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void;
150}) {
151 const key = keys[0];
152 if (!key || targets.length === 0) return null;
153 const overrideSummary =
154 overridingProjects.length > 0
155 ? `${summary} · ${overridingProjects.length} project ${overridingProjects.length === 1 ? "override" : "overrides"}`
156 : summary;
157 const chains = targets.flatMap((target) => {
158 const environment = environments.find(
159 (candidate) => candidate.environmentId === target.environmentId,
160 );
161 if (!environment?.serverConfig) return [];
162 return [
163 {
164 target,
165 environment: { ...environment, serverConfig: environment.serverConfig },
166 machine: resolveEnvironmentMachineKind(environment.serverConfig),
167 layers: settingInheritanceLayers(target, environment.serverConfig.settings, key),
168 },
169 ];
170 });
171 return (
172 <Popover>
173 <Tooltip>
174 <TooltipTrigger
175 render={
176 <PopoverTrigger
177 render={
178 <Button
179 size="icon-micro"
180 variant="ghost-muted"
181 aria-label={`${overrideSummary}. Show where this value comes from`}
182 className={cn(
183 "[--control-icon-color:currentColor]",
184 state === "overridden"
185 ? "text-primary hover:text-primary"
186 : state === "mixed"
187 ? "text-warning hover:text-warning"
188 : state === "environment"
189 ? "text-foreground/70 hover:text-foreground"
190 : "text-muted-foreground/60 hover:text-foreground",
191 )}
192 />
193 }
194 />
195 }
196 >
197 <LayersIcon className="size-3" />
198 </TooltipTrigger>
199 <TooltipPopup side="top">{overrideSummary}</TooltipPopup>
200 </Tooltip>
201 <PopoverPopup
202 align="start"
203 className="w-72 max-w-[calc(100vw-2rem)]"
204 viewportClassName="p-0 [--viewport-inline-padding:0px]"
205 >
206 <div className="divide-y divide-border/60">
207 {chains.map(({ target, environment, machine, layers }) => (
208 <section
209 key={`${target.environmentId}:${target.projectId ?? ""}`}
210 className="px-3 py-2.5"
211 >
212 <h4 className="flex items-center gap-1.5 pb-1.5 text-xs font-medium text-muted-foreground">
213 <EnvironmentMachineIcon aria-hidden kind={machine} className="size-3.5 shrink-0" />
214 <span className="min-w-0 truncate">{target.label}</span>
215 </h4>
216 <ol role="list" className="text-sm">
217 {layers.map((layer) => (
218 <li
219 key={layer.key}
220 className={cn(
221 "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
222 layer.effective && "bg-foreground/[0.06]",
223 )}
224 >
225 <span
226 className={cn(
227 "min-w-0 truncate",
228 layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
229 )}
230 >
231 {layer.key === "environment" ? "Environment" : layer.label}
232 </span>
233 <span
234 className={cn(
235 "flex items-center gap-1.5 tabular-nums",
236 layer.effective
237 ? "text-foreground"
238 : layer.set
239 ? "text-muted-foreground"
240 : "text-muted-foreground/60",
241 )}
242 >
243 <span className="max-w-32 truncate">{layer.value}</span>
244 {layer.effective ? (
245 <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
246 ) : (
247 <span aria-hidden className="size-3.5 shrink-0" />
248 )}
249 </span>
250 </li>
251 ))}
252 </ol>
253 {(() => {
254 const overriding = overridingProjects.filter(
255 (project) => project.environmentId === target.environmentId,
256 );
257 if (overriding.length === 0) return null;
258 const overrides = environment.serverConfig.settings.projectSettingsOverrides;
259 return (
260 <div className="mt-2 border-t border-border/60 pt-2">
261 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
262 <span>Overridden by</span>
263 {onClearOverrides ? (
264 <button
265 type="button"
266 className="cursor-pointer font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
267 onClick={() => onClearOverrides(overriding)}
268 >
269 Reset {overriding.length === 1 ? "it" : "all"}
270 </button>
271 ) : null}
272 </div>
273 <ul role="list" className="mt-0.5 text-sm">
274 {overriding.map((project) => (
275 <li
276 key={project.projectId}
277 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"
278 >
279 <button
280 type="button"
281 className="min-w-0 cursor-pointer truncate text-left text-foreground underline-offset-2 hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
282 onClick={project.open}
283 >
284 {project.label}
285 </button>
286 <span className="max-w-32 truncate text-muted-foreground tabular-nums">
287 {isProjectScopedSettingKey(key)
288 ? formatValue(key, overrides[project.projectId]?.[key])
289 : null}
290 </span>
291 </li>
292 ))}
293 </ul>
294 </div>
295 );
296 })()}
297 </section>
298 ))}
299 </div>
300 </PopoverPopup>
301 </Popover>
302 );
303}
apps/web/src/components/settings/SettingsBreadcrumb.tsx+201-1
...@@ -1,9 +1,37 @@...@@ -1,9 +1,37 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { LayersIcon } from "lucide-react";
3import type { ReactNode } from "react";
4
5import { cn } from "../../lib/utils";
6import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
7import type { EnvironmentPresentation } from "../../state/environments";
8import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
9import { ProjectFavicon } from "../ProjectFavicon";
10import {
11 Menu,
12 MenuPopup,
13 MenuRadioGroup,
14 MenuRadioItem,
15 MenuRadioItemIndicator,
16 MenuSeparator,
17 MenuTrigger,
18} from "../ui/menu";
1import {19import {
2 WorkspaceBreadcrumb,20 WorkspaceBreadcrumb,
3 WorkspaceBreadcrumbItem,21 WorkspaceBreadcrumbItem,
4 WorkspaceBreadcrumbSeparator,22 WorkspaceBreadcrumbSeparator,
5} from "../WorkspaceBreadcrumb";23} from "../WorkspaceBreadcrumb";
6import { SETTINGS_SECTION_LABELS } from "./settingsSearch";24import { SETTINGS_SECTION_LABELS } from "./settingsSearch";
25import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
26import {
27 ALL_ENVIRONMENTS_VALUE,
28 ALL_PROJECTS_VALUE,
29 environmentAxisValue,
30 projectAxisValue,
31 selectEnvironmentAxis,
32 selectProjectAxis,
33 settingsScopeEnvironmentLabel,
34} from "./settingsScopeAxis";
735
8const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {36const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
9 ...SETTINGS_SECTION_LABELS,37 ...SETTINGS_SECTION_LABELS,
...@@ -15,7 +43,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null {...@@ -15,7 +43,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null {
15 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;43 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
16}44}
1745
18export function SettingsBreadcrumb({ pathname }: { pathname: string }) {46export interface SettingsScopeBreadcrumbProps {
47 readonly value: SettingsScopeSearch;
48 readonly groups: readonly SidebarProjectSnapshot[];
49 readonly environments: readonly EnvironmentPresentation[];
50 readonly onChange: (next: SettingsScopeSearch) => void;
51}
52
53/**
54 * `Settings / Section / Environment / Project`. The last two crumbs are the
55 * targets a change applies to and read like the usage page's filter: muted at
56 * "all", foreground once narrowed. A project is the same project on every
57 * environment, so the environment crumb alone decides where a project
58 * override is written.
59 */
60export function SettingsBreadcrumb({
61 pathname,
62 scope,
63}: {
64 pathname: string;
65 scope?: SettingsScopeBreadcrumbProps | undefined;
66}) {
19 const sectionLabel = settingsBreadcrumbLabel(pathname);67 const sectionLabel = settingsBreadcrumbLabel(pathname);
2068
21 return (69 return (
...@@ -29,6 +77,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) {...@@ -29,6 +77,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
29 <WorkspaceBreadcrumbItem current className="truncate">77 <WorkspaceBreadcrumbItem current className="truncate">
30 {sectionLabel ?? "Settings"}78 {sectionLabel ?? "Settings"}
31 </WorkspaceBreadcrumbItem>79 </WorkspaceBreadcrumbItem>
80 {scope ? (
81 <>
82 <WorkspaceBreadcrumbSeparator />
83 <WorkspaceBreadcrumbItem className="min-w-0 shrink">
84 <EnvironmentScopeMenu {...scope} />
85 </WorkspaceBreadcrumbItem>
86 <WorkspaceBreadcrumbSeparator />
87 <WorkspaceBreadcrumbItem className="min-w-0 shrink">
88 <ProjectScopeMenu {...scope} />
89 </WorkspaceBreadcrumbItem>
90 </>
91 ) : null}
32 </WorkspaceBreadcrumb>92 </WorkspaceBreadcrumb>
33 );93 );
34}94}
95
96function ScopeMenu({
97 ariaLabel,
98 icon,
99 label,
100 narrowed,
101 children,
102}: {
103 ariaLabel: string;
104 icon: ReactNode;
105 label: string;
106 narrowed: boolean;
107 children: ReactNode;
108}) {
109 return (
110 <Menu>
111 <MenuTrigger
112 aria-label={ariaLabel}
113 className={cn(
114 "inline-flex min-w-0 max-w-56 cursor-pointer items-center gap-1.5 rounded-sm text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
115 narrowed ? "text-foreground" : "text-muted-foreground hover:text-foreground",
116 )}
117 >
118 {icon}
119 <span className="min-w-0 truncate">{label}</span>
120 </MenuTrigger>
121 <MenuPopup align="start" className="w-64 max-w-[calc(100vw-2rem)]">
122 {children}
123 </MenuPopup>
124 </Menu>
125 );
126}
127
128function EnvironmentScopeMenu({
129 value,
130 groups,
131 environments,
132 onChange,
133}: SettingsScopeBreadcrumbProps) {
134 const resolved = resolveSettingsScope(value, groups, environments);
135 const environmentValue = environmentAxisValue(
136 value,
137 resolved.kind === "checkout" ? resolved.environmentId : null,
138 );
139 const selected = environments.find(
140 (environment) => environment.environmentId === environmentValue,
141 );
142 return (
143 <ScopeMenu
144 ariaLabel="Environment scope"
145 narrowed={environmentValue !== ALL_ENVIRONMENTS_VALUE}
146 icon={
147 selected ? (
148 <EnvironmentMachineIcon
149 aria-hidden
150 kind={resolveEnvironmentMachineKind(selected.serverConfig)}
151 className="size-3.5 shrink-0"
152 />
153 ) : null
154 }
155 label={
156 selected
157 ? settingsScopeEnvironmentLabel(selected, environments)
158 : environmentValue !== ALL_ENVIRONMENTS_VALUE
159 ? "Unavailable environment"
160 : "All environments"
161 }
162 >
163 <MenuRadioGroup
164 value={environmentValue}
165 onValueChange={(next) => {
166 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
167 }}
168 >
169 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>
170 <span className="flex min-w-0 items-center gap-2">
171 <LayersIcon aria-hidden className="size-3.5" />
172 <span className="min-w-0 flex-1 truncate">All environments</span>
173 <MenuRadioItemIndicator />
174 </span>
175 </MenuRadioItem>
176 <MenuSeparator />
177 {environments.map((environment) => (
178 <MenuRadioItem key={environment.environmentId} value={environment.environmentId}>
179 <span className="flex min-w-0 items-center gap-2">
180 <EnvironmentMachineIcon
181 aria-hidden
182 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
183 className="size-3.5"
184 />
185 <span className="min-w-0 flex-1 truncate">
186 {settingsScopeEnvironmentLabel(environment, environments)}
187 </span>
188 {environment.connection.phase === "connected" ? null : (
189 <span className="shrink-0 text-xs text-muted-foreground">Offline</span>
190 )}
191 <MenuRadioItemIndicator />
192 </span>
193 </MenuRadioItem>
194 ))}
195 </MenuRadioGroup>
196 </ScopeMenu>
197 );
198}
199
200function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) {
201 const selected = groups.find((group) => group.projectKey === value.project);
202 return (
203 <ScopeMenu
204 ariaLabel="Project scope"
205 narrowed={value.project !== undefined}
206 icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null}
207 label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")}
208 >
209 <MenuRadioGroup
210 value={projectAxisValue(value)}
211 onValueChange={(next) => {
212 if (typeof next === "string") onChange(selectProjectAxis(value, next));
213 }}
214 >
215 <MenuRadioItem value={ALL_PROJECTS_VALUE}>
216 <span className="flex min-w-0 items-center gap-2">
217 <span className="min-w-0 flex-1 truncate">All projects</span>
218 <MenuRadioItemIndicator />
219 </span>
220 </MenuRadioItem>
221 <MenuSeparator />
222 {groups.map((group) => (
223 <MenuRadioItem key={group.projectKey} value={group.projectKey}>
224 <span className="flex min-w-0 items-center gap-2">
225 <ProjectFavicon project={group} className="size-3.5" />
226 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
227 <MenuRadioItemIndicator />
228 </span>
229 </MenuRadioItem>
230 ))}
231 </MenuRadioGroup>
232 </ScopeMenu>
233 );
234}
apps/web/src/components/settings/SettingsPanels.tsx+153-119
...@@ -3,7 +3,6 @@ import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-re...@@ -3,7 +3,6 @@ import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-re
3import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
4import type { CSSProperties, ReactNode } from "react";4import type { CSSProperties, ReactNode } from "react";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { useAtomValue } from "@effect/atom-react";
7import {6import {
8 type BackgroundActivityProfile,7 type BackgroundActivityProfile,
9 type DesktopUpdateChannel,8 type DesktopUpdateChannel,
...@@ -70,13 +69,19 @@ import {...@@ -70,13 +69,19 @@ import {
70 useTheme,69 useTheme,
71} from "../../hooks/useTheme";70} from "../../hooks/useTheme";
72import { useLocalStorage } from "../../hooks/useLocalStorage";71import { useLocalStorage } from "../../hooks/useLocalStorage";
73import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";72import {
73 useScopedSettings,
74 useScopedSettingsMixed,
75 useUpdateScopedSettings,
76} from "./useScopedSettings";
77import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
78import { useSettingsScope } from "./SettingsScopeContext";
79import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
74import { useThreadActions } from "../../hooks/useThreadActions";80import { useThreadActions } from "../../hooks/useThreadActions";
75import { useDesktopUpdateState } from "../../state/desktopUpdate";81import { useDesktopUpdateState } from "../../state/desktopUpdate";
76import {82import {
77 getCustomModelOptionsByInstance,83 getCustomModelOptionsByInstance,
78 resolveAppModelSelectionState,84 resolveAppModelSelectionState,
79 withoutPlanAgentSelection,
80} from "../../modelSelection";85} from "../../modelSelection";
81import {86import {
82 applyProviderInstanceSettings,87 applyProviderInstanceSettings,
...@@ -85,13 +90,7 @@ import {...@@ -85,13 +90,7 @@ import {
85} from "../../providerInstances";90} from "../../providerInstances";
86import { ensureLocalApi, readLocalApi } from "../../localApi";91import { ensureLocalApi, readLocalApi } from "../../localApi";
87import { isMacPlatform } from "../../lib/utils";92import { isMacPlatform } from "../../lib/utils";
88import {93import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
89 primaryServerConfigAtom,
90 primaryServerObservabilityAtom,
91 primaryServerProvidersAtom,
92} from "../../state/server";
93import { useProjects } from "../../state/entities";
94import { usePrimaryEnvironmentId } from "../../state/environments";
95import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";94import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
96import { formatRelativeTimeLabel } from "../../timestampFormat";95import { formatRelativeTimeLabel } from "../../timestampFormat";
97import { Button } from "../ui/button";96import { Button } from "../ui/button";
...@@ -118,7 +117,6 @@ import {...@@ -118,7 +117,6 @@ import {
118 TYPOGRAPHY_ADVANCED_STORAGE_KEY,117 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
119} from "../../appearanceFonts";118} from "../../appearanceFonts";
120import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";119import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
121import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
122import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";120import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
123import {121import {
124 NumberField,122 NumberField,
...@@ -129,6 +127,7 @@ import {...@@ -129,6 +127,7 @@ import {
129} from "../ui/number-field";127} from "../ui/number-field";
130import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";128import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
131import { Switch } from "../ui/switch";129import { Switch } from "../ui/switch";
130import { ScopedSwitch } from "./ScopedSwitch";
132import { stackedThreadToast, toastManager } from "../ui/toast";131import { stackedThreadToast, toastManager } from "../ui/toast";
133import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";132import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
134import { ThemeLibrary } from "./ThemeSettings";133import { ThemeLibrary } from "./ThemeSettings";
...@@ -136,7 +135,6 @@ import {...@@ -136,7 +135,6 @@ import {
136 backgroundActivityOverrideSettings,135 backgroundActivityOverrideSettings,
137 backgroundActivitySharedPolicySettings,136 backgroundActivitySharedPolicySettings,
138 durationToSeconds,137 durationToSeconds,
139 formatDiagnosticsDescription,
140 getChangedBrowserSettingLabels,138 getChangedBrowserSettingLabels,
141 getChangedTypographySettingLabels,139 getChangedTypographySettingLabels,
142 normalizeIntervalSeconds,140 normalizeIntervalSeconds,
...@@ -488,8 +486,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -488,8 +486,8 @@ export function useSettingsRestore(onRestored?: () => void) {
488 clearThemeHalves,486 clearThemeHalves,
489 themeHalves,487 themeHalves,
490 } = useTheme();488 } = useTheme();
491 const settings = usePrimarySettings();489 const settings = useScopedSettings();
492 const updateSettings = useUpdatePrimarySettings();490 const updateSettings = useUpdateScopedSettings();
493491
494 const isTextGenerationModelDirty = !Equal.equals(492 const isTextGenerationModelDirty = !Equal.equals(
495 settings.textGenerationModelSelection ?? null,493 settings.textGenerationModelSelection ?? null,
...@@ -781,8 +779,8 @@ function BackgroundActivityAdvancedDialog({...@@ -781,8 +779,8 @@ function BackgroundActivityAdvancedDialog({
781 readonly open: boolean;779 readonly open: boolean;
782 readonly onOpenChange: (open: boolean) => void;780 readonly onOpenChange: (open: boolean) => void;
783}) {781}) {
784 const settings = usePrimarySettings();782 const settings = useScopedSettings();
785 const updateSettings = useUpdatePrimarySettings();783 const updateSettings = useUpdateScopedSettings();
786 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);784 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
787 const activeProfile = resolvedBackgroundActivity.profile;785 const activeProfile = resolvedBackgroundActivity.profile;
788 const automaticGitFetchIntervalSeconds = durationToSeconds(786 const automaticGitFetchIntervalSeconds = durationToSeconds(
...@@ -1059,8 +1057,8 @@ export function AppearanceSettingsPanel() {...@@ -1059,8 +1057,8 @@ export function AppearanceSettingsPanel() {
1059 } = useTheme();1057 } = useTheme();
1060 const customThemes = useCustomThemes();1058 const customThemes = useCustomThemes();
1061 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);1059 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
1062 const settings = usePrimarySettings();1060 const settings = useScopedSettings();
1063 const updateSettings = useUpdatePrimarySettings();1061 const updateSettings = useUpdateScopedSettings();
1064 const environmentStageLabel = useEnvironmentStageLabel();1062 const environmentStageLabel = useEnvironmentStageLabel();
1065 const showEnvironmentIdentification =1063 const showEnvironmentIdentification =
1066 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;1064 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
...@@ -1353,7 +1351,7 @@ export function AppearanceSettingsPanel() {...@@ -1353,7 +1351,7 @@ export function AppearanceSettingsPanel() {
1353}1351}
13541352
1355function useFontDefaultFamilies() {1353function useFontDefaultFamilies() {
1356 const settings = usePrimarySettings();1354 const settings = useScopedSettings();
1357 // An unset preference shows the font it resolves to on this machine; the1355 // An unset preference shows the font it resolves to on this machine; the
1358 // default stacks are the platform's own faces, so the name is probed, not1356 // default stacks are the platform's own faces, so the name is probed, not
1359 // hardcoded.1357 // hardcoded.
...@@ -1373,8 +1371,8 @@ function useFontDefaultFamilies() {...@@ -1373,8 +1371,8 @@ function useFontDefaultFamilies() {
1373}1371}
13741372
1375function InterfaceFontRow({ preview }: { preview?: ReactNode }) {1373function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
1376 const settings = usePrimarySettings();1374 const settings = useScopedSettings();
1377 const updateSettings = useUpdatePrimarySettings();1375 const updateSettings = useUpdateScopedSettings();
1378 const defaults = useFontDefaultFamilies();1376 const defaults = useFontDefaultFamilies();
1379 return (1377 return (
1380 <FontFamilySettingsRow1378 <FontFamilySettingsRow
...@@ -1404,8 +1402,8 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) {...@@ -1404,8 +1402,8 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
1404}1402}
14051403
1406function PromptFontRow() {1404function PromptFontRow() {
1407 const settings = usePrimarySettings();1405 const settings = useScopedSettings();
1408 const updateSettings = useUpdatePrimarySettings();1406 const updateSettings = useUpdateScopedSettings();
1409 const defaults = useFontDefaultFamilies();1407 const defaults = useFontDefaultFamilies();
1410 return (1408 return (
1411 <FontFamilySettingsRow1409 <FontFamilySettingsRow
...@@ -1443,8 +1441,8 @@ function CodeFontRow({...@@ -1443,8 +1441,8 @@ function CodeFontRow({
1443 description?: string;1441 description?: string;
1444 preview?: ReactNode;1442 preview?: ReactNode;
1445}) {1443}) {
1446 const settings = usePrimarySettings();1444 const settings = useScopedSettings();
1447 const updateSettings = useUpdatePrimarySettings();1445 const updateSettings = useUpdateScopedSettings();
1448 const defaults = useFontDefaultFamilies();1446 const defaults = useFontDefaultFamilies();
1449 return (1447 return (
1450 <FontFamilySettingsRow1448 <FontFamilySettingsRow
...@@ -1476,8 +1474,8 @@ function CodeFontRow({...@@ -1476,8 +1474,8 @@ function CodeFontRow({
1476}1474}
14771475
1478function TerminalFontRow() {1476function TerminalFontRow() {
1479 const settings = usePrimarySettings();1477 const settings = useScopedSettings();
1480 const updateSettings = useUpdatePrimarySettings();1478 const updateSettings = useUpdateScopedSettings();
1481 const defaults = useFontDefaultFamilies();1479 const defaults = useFontDefaultFamilies();
1482 return (1480 return (
1483 <FontFamilySettingsRow1481 <FontFamilySettingsRow
...@@ -1517,8 +1515,8 @@ function TerminalFontRow() {...@@ -1517,8 +1515,8 @@ function TerminalFontRow() {
1517}1515}
15181516
1519function FontSmoothingRow() {1517function FontSmoothingRow() {
1520 const settings = usePrimarySettings();1518 const settings = useScopedSettings();
1521 const updateSettings = useUpdatePrimarySettings();1519 const updateSettings = useUpdateScopedSettings();
1522 if (!isMacPlatform(navigator.platform)) return null;1520 if (!isMacPlatform(navigator.platform)) return null;
1523 return (1521 return (
1524 <SettingsRow1522 <SettingsRow
...@@ -1546,8 +1544,8 @@ function FontSmoothingRow() {...@@ -1546,8 +1544,8 @@ function FontSmoothingRow() {
1546}1544}
15471545
1548function WordWrapRow() {1546function WordWrapRow() {
1549 const settings = usePrimarySettings();1547 const settings = useScopedSettings();
1550 const updateSettings = useUpdatePrimarySettings();1548 const updateSettings = useUpdateScopedSettings();
1551 return (1549 return (
1552 <SettingsRow1550 <SettingsRow
1553 {...searchableSetting("word-wrap")}1551 {...searchableSetting("word-wrap")}
...@@ -1589,7 +1587,7 @@ function FontSettingsGroup() {...@@ -1589,7 +1587,7 @@ function FontSettingsGroup() {
1589 * under each row show every surface the choice reaches.1587 * under each row show every surface the choice reaches.
1590 */1588 */
1591function SimpleFontRows() {1589function SimpleFontRows() {
1592 const settings = usePrimarySettings();1590 const settings = useScopedSettings();
1593 return (1591 return (
1594 <>1592 <>
1595 <InterfaceFontRow preview={<PromptFontPreview />} />1593 <InterfaceFontRow preview={<PromptFontPreview />} />
...@@ -1930,8 +1928,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([...@@ -1930,8 +1928,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
1930 * jump to one of the rows unfolds the section.1928 * jump to one of the rows unfolds the section.
1931 */1929 */
1932function LegacyFeaturesSection() {1930function LegacyFeaturesSection() {
1933 const settings = usePrimarySettings();1931 const settings = useScopedSettings();
1934 const updateSettings = useUpdatePrimarySettings();1932 const updateSettings = useUpdateScopedSettings();
1935 const [open, setOpen] = useState(false);1933 const [open, setOpen] = useState(false);
1936 const searchTargetId = useSettingsSearchTargetId();1934 const searchTargetId = useSettingsSearchTargetId();
1937 const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features");1935 const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features");
...@@ -1969,29 +1967,7 @@ function LegacyFeaturesSection() {...@@ -1969,29 +1967,7 @@ function LegacyFeaturesSection() {
1969 <Switch1967 <Switch
1970 checked={settings.planModeEnabled}1968 checked={settings.planModeEnabled}
1971 onCheckedChange={(checked) => {1969 onCheckedChange={(checked) => {
1972 const planModeEnabled = Boolean(checked);1970 updateSettings({ planModeEnabled: Boolean(checked) });
1973 const textGenerationModelSelection = withoutPlanAgentSelection(
1974 settings.textGenerationModelSelection,
1975 );
1976 const sourceControlWriterModelSelection = withoutPlanAgentSelection(
1977 settings.sourceControlWriterModelSelection,
1978 );
1979 updateSettings({
1980 planModeEnabled,
1981 ...(planModeEnabled
1982 ? {}
1983 : {
1984 ...(textGenerationModelSelection &&
1985 textGenerationModelSelection !== settings.textGenerationModelSelection
1986 ? { textGenerationModelSelection }
1987 : {}),
1988 ...(sourceControlWriterModelSelection &&
1989 sourceControlWriterModelSelection !==
1990 settings.sourceControlWriterModelSelection
1991 ? { sourceControlWriterModelSelection }
1992 : {}),
1993 }),
1994 });
1995 }}1971 }}
1996 aria-label="Plan mode (legacy)"1972 aria-label="Plan mode (legacy)"
1997 />1973 />
...@@ -2012,10 +1988,12 @@ function LegacyFeaturesSection() {...@@ -2012,10 +1988,12 @@ function LegacyFeaturesSection() {
2012 />1988 />
2013 <SettingsRow1989 <SettingsRow
2014 serverScoped1990 serverScoped
1991 settingKeys={["enableLegacyTokenStreaming"]}
2015 {...searchableSetting("legacy-token-streaming")}1992 {...searchableSetting("legacy-token-streaming")}
2016 description="Stream output token by token. This legacy mode is slower and harder to follow."1993 description="Stream output token by token. This legacy mode is slower and harder to follow."
2017 control={1994 control={
2018 <Switch1995 <ScopedSwitch
1996 settingKeys={["enableLegacyTokenStreaming"]}
2019 checked={settings.enableLegacyTokenStreaming}1997 checked={settings.enableLegacyTokenStreaming}
2020 onCheckedChange={(checked) => {1998 onCheckedChange={(checked) => {
2021 if (!checked) {1999 if (!checked) {
...@@ -2058,25 +2036,32 @@ function LegacyFeaturesSection() {...@@ -2058,25 +2036,32 @@ function LegacyFeaturesSection() {
2058}2036}
20592037
2060export function GeneralSettingsPanel() {2038export function GeneralSettingsPanel() {
2061 const settings = usePrimarySettings();2039 const settings = useScopedSettings();
2062 const updateSettings = useUpdatePrimarySettings();2040 const updateSettings = useUpdateScopedSettings();
2063 const navigate = useNavigate();2041 const navigate = useNavigate();
2064 const environmentId = usePrimaryEnvironmentId();2042 const { scope, environment, connectedEnvironments } = useSettingsScope();
2043 // The representative environment supplies the provider list for pickers;
2044 // a fanned-out model choice is validated against every target before it
2045 // is written. Per-machine tuning (background activity overrides) still
2046 // needs exactly one environment.
2047 const environmentId = environment?.environmentId ?? null;
2048 const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
2049 const hasServerTargets = connectedEnvironments.length > 0;
2065 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);2050 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
2066 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(2051 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
2067 readLastEnabledProjectGroupingMode(),2052 readLastEnabledProjectGroupingMode(),
2068 );2053 );
2069 const observability = useAtomValue(primaryServerObservabilityAtom);2054 const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
2070 const serverProviders = useAtomValue(primaryServerProvidersAtom);
2071 const supportsAutoSettlement =2055 const supportsAutoSettlement =
2072 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;2056 connectedEnvironments.length > 0 &&
2073 const diagnosticsDescription = formatDiagnosticsDescription({2057 connectedEnvironments.every(
2074 localTracingEnabled: observability?.localTracingEnabled ?? false,2058 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
2075 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,2059 );
2076 otlpTracesUrl: observability?.otlpTracesUrl,2060 const supportsRestartContinuation =
2077 otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false,2061 connectedEnvironments.length > 0 &&
2078 otlpMetricsUrl: observability?.otlpMetricsUrl,2062 connectedEnvironments.every(
2079 });2063 (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true,
2064 );
20802065
2081 const textGenerationProviders = serverProviders.filter(2066 const textGenerationProviders = serverProviders.filter(
2082 (provider) => provider.supportsTextGeneration !== false,2067 (provider) => provider.supportsTextGeneration !== false,
...@@ -2109,9 +2094,16 @@ export function GeneralSettingsPanel() {...@@ -2109,9 +2094,16 @@ export function GeneralSettingsPanel() {
2109 settings.textGenerationModelSelection ?? null,2094 settings.textGenerationModelSelection ?? null,
2110 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,2095 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
2111 );2096 );
2097 const textGenerationModelDisabledReason = useScopedModelDisabledReason(
2098 settings,
2099 textGenerationModelInstanceEntries,
2100 );
2112 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);2101 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
2113 const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;2102 const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
2114 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);2103 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
2104 const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]);
2105 const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
2106 const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
2115 const backgroundActivityDescription =2107 const backgroundActivityDescription =
2116 backgroundActivityProfileOption === "advanced"2108 backgroundActivityProfileOption === "advanced"
2117 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${2109 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${
...@@ -2125,7 +2117,7 @@ export function GeneralSettingsPanel() {...@@ -2125,7 +2117,7 @@ export function GeneralSettingsPanel() {
21252117
2126 return (2118 return (
2127 <SettingsPageContainer>2119 <SettingsPageContainer>
2128 <SharedSettingsMismatchAlert />2120 <ProjectDefaultsSettings category="general" />
2129 <SettingsSection id="organization" title="Organization">2121 <SettingsSection id="organization" title="Organization">
2130 <SettingsRow2122 <SettingsRow
2131 {...searchableSetting("project-grouping")}2123 {...searchableSetting("project-grouping")}
...@@ -2167,6 +2159,7 @@ export function GeneralSettingsPanel() {...@@ -2167,6 +2159,7 @@ export function GeneralSettingsPanel() {
2167 <>2159 <>
2168 <SettingsRow2160 <SettingsRow
2169 serverScoped2161 serverScoped
2162 settingKeys={["sidebarAutoSettleOnMerge"]}
2170 {...searchableSetting("auto-settle-merged-threads")}2163 {...searchableSetting("auto-settle-merged-threads")}
2171 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."2164 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
2172 resetAction={2165 resetAction={
...@@ -2183,7 +2176,8 @@ export function GeneralSettingsPanel() {...@@ -2183,7 +2176,8 @@ export function GeneralSettingsPanel() {
2183 ) : null2176 ) : null
2184 }2177 }
2185 control={2178 control={
2186 <Switch2179 <ScopedSwitch
2180 settingKeys={["sidebarAutoSettleOnMerge"]}
2187 checked={settings.sidebarAutoSettleOnMerge}2181 checked={settings.sidebarAutoSettleOnMerge}
2188 onCheckedChange={(checked) =>2182 onCheckedChange={(checked) =>
2189 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })2183 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
...@@ -2195,6 +2189,7 @@ export function GeneralSettingsPanel() {...@@ -2195,6 +2189,7 @@ export function GeneralSettingsPanel() {
21952189
2196 <SettingsRow2190 <SettingsRow
2197 serverScoped2191 serverScoped
2192 settingKeys={["sidebarAutoSettleAfterDays"]}
2198 {...searchableSetting("auto-settle-inactive-threads")}2193 {...searchableSetting("auto-settle-inactive-threads")}
2199 description="Sidebar threads with no activity for this long settle automatically."2194 description="Sidebar threads with no activity for this long settle automatically."
2200 resetAction={2195 resetAction={
...@@ -2212,7 +2207,8 @@ export function GeneralSettingsPanel() {...@@ -2212,7 +2207,8 @@ export function GeneralSettingsPanel() {
2212 ) : null2207 ) : null
2213 }2208 }
2214 control={2209 control={
2215 <Switch2210 <ScopedSwitch
2211 settingKeys={["sidebarAutoSettleAfterDays"]}
2216 checked={settings.sidebarAutoSettleAfterDays !== null}2212 checked={settings.sidebarAutoSettleAfterDays !== null}
2217 onCheckedChange={(checked) =>2213 onCheckedChange={(checked) =>
2218 updateSettings({2214 updateSettings({
...@@ -2226,6 +2222,7 @@ export function GeneralSettingsPanel() {...@@ -2226,6 +2222,7 @@ export function GeneralSettingsPanel() {
2226 {settings.sidebarAutoSettleAfterDays !== null ? (2222 {settings.sidebarAutoSettleAfterDays !== null ? (
2227 <SettingsRow2223 <SettingsRow
2228 serverScoped2224 serverScoped
2225 settingKeys={["sidebarAutoSettleAfterDays"]}
2229 title={searchableSetting("days-before-auto-settle").title}2226 title={searchableSetting("days-before-auto-settle").title}
2230 description="Any new activity un-settles a thread automatically."2227 description="Any new activity un-settles a thread automatically."
2231 control={2228 control={
...@@ -2423,6 +2420,7 @@ export function GeneralSettingsPanel() {...@@ -2423,6 +2420,7 @@ export function GeneralSettingsPanel() {
24232420
2424 <SettingsRow2421 <SettingsRow
2425 serverScoped2422 serverScoped
2423 settingKeys={["enableProviderUpdateChecks"]}
2426 {...searchableSetting("provider-update-checks")}2424 {...searchableSetting("provider-update-checks")}
2427 description="Check installed provider CLIs for newer available versions."2425 description="Check installed provider CLIs for newer available versions."
2428 resetAction={2426 resetAction={
...@@ -2439,7 +2437,8 @@ export function GeneralSettingsPanel() {...@@ -2439,7 +2437,8 @@ export function GeneralSettingsPanel() {
2439 ) : null2437 ) : null
2440 }2438 }
2441 control={2439 control={
2442 <Switch2440 <ScopedSwitch
2441 settingKeys={["enableProviderUpdateChecks"]}
2443 checked={settings.enableProviderUpdateChecks}2442 checked={settings.enableProviderUpdateChecks}
2444 onCheckedChange={(checked) =>2443 onCheckedChange={(checked) =>
2445 updateSettings({ enableProviderUpdateChecks: Boolean(checked) })2444 updateSettings({ enableProviderUpdateChecks: Boolean(checked) })
...@@ -2452,10 +2451,17 @@ export function GeneralSettingsPanel() {...@@ -2452,10 +2451,17 @@ export function GeneralSettingsPanel() {
2452 <SettingsRow2451 <SettingsRow
2453 {...searchableSetting("continue-threads-after-server-update")}2452 {...searchableSetting("continue-threads-after-server-update")}
2454 serverScoped2453 serverScoped
2455 description="Automatically resume interrupted threads after an update, crash, or machine restart. Applies to this environment and all connected environments that support it. Update older servers first."2454 settingKeys={["continueThreadsAfterServerUpdate"]}
2455 description="Automatically resume interrupted threads after an update, crash, or machine restart on the selected environments. Update older servers first."
2456 status={
2457 !supportsRestartContinuation
2458 ? "All selected connected environments must support restart continuation."
2459 : undefined
2460 }
2456 resetAction={2461 resetAction={
2462 supportsRestartContinuation &&
2457 settings.continueThreadsAfterServerUpdate !==2463 settings.continueThreadsAfterServerUpdate !==
2458 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (2464 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
2459 <SettingResetButton2465 <SettingResetButton
2460 label="continue threads after restarts"2466 label="continue threads after restarts"
2461 onClick={() =>2467 onClick={() =>
...@@ -2468,8 +2474,10 @@ export function GeneralSettingsPanel() {...@@ -2468,8 +2474,10 @@ export function GeneralSettingsPanel() {
2468 ) : null2474 ) : null
2469 }2475 }
2470 control={2476 control={
2471 <Switch2477 <ScopedSwitch
2478 settingKeys={["continueThreadsAfterServerUpdate"]}
2472 checked={settings.continueThreadsAfterServerUpdate}2479 checked={settings.continueThreadsAfterServerUpdate}
2480 disabled={!supportsRestartContinuation}
2473 onCheckedChange={(checked) =>2481 onCheckedChange={(checked) =>
2474 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })2482 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
2475 }2483 }
...@@ -2480,6 +2488,7 @@ export function GeneralSettingsPanel() {...@@ -2480,6 +2488,7 @@ export function GeneralSettingsPanel() {
24802488
2481 <SettingsRow2489 <SettingsRow
2482 serverScoped2490 serverScoped
2491 settingKeys={["backgroundActivity"]}
2483 id={searchableSetting("background-activity").id}2492 id={searchableSetting("background-activity").id}
2484 title={2493 title={
2485 <span className="inline-flex items-center gap-1.5">2494 <span className="inline-flex items-center gap-1.5">
...@@ -2502,10 +2511,10 @@ export function GeneralSettingsPanel() {...@@ -2502,10 +2511,10 @@ export function GeneralSettingsPanel() {
2502 control={2511 control={
2503 <>2512 <>
2504 <Select2513 <Select
2505 value={backgroundActivityProfileOption}2514 value={mixedBackgroundActivity ? null : backgroundActivityProfileOption}
2506 onValueChange={(value) => {2515 onValueChange={(value) => {
2507 if (value === "advanced") {2516 if (value === "advanced") {
2508 setBackgroundActivityDialogOpen(true);2517 if (isEnvironmentScope) setBackgroundActivityDialogOpen(true);
2509 return;2518 return;
2510 }2519 }
2511 if (2520 if (
...@@ -2523,7 +2532,9 @@ export function GeneralSettingsPanel() {...@@ -2523,7 +2532,9 @@ export function GeneralSettingsPanel() {
2523 aria-label="Background activity profile"2532 aria-label="Background activity profile"
2524 >2533 >
2525 <SelectValue>2534 <SelectValue>
2526 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]}2535 {(value: BackgroundActivityProfileOption | null) =>
2536 value === null ? "Mixed" : BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[value]
2537 }
2527 </SelectValue>2538 </SelectValue>
2528 </SelectTrigger>2539 </SelectTrigger>
2529 <SelectPopup align="end" alignItemWithTrigger={false}>2540 <SelectPopup align="end" alignItemWithTrigger={false}>
...@@ -2536,12 +2547,14 @@ export function GeneralSettingsPanel() {...@@ -2536,12 +2547,14 @@ export function GeneralSettingsPanel() {
2536 <SelectItem hideIndicator value="battery-saver">2547 <SelectItem hideIndicator value="battery-saver">
2537 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}2548 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
2538 </SelectItem>2549 </SelectItem>
2539 <SelectItem hideIndicator value="advanced">2550 <SelectItem hideIndicator value="advanced" disabled={!isEnvironmentScope}>
2540 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced}2551 {isEnvironmentScope
2552 ? BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced
2553 : `${BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} (one environment)`}
2541 </SelectItem>2554 </SelectItem>
2542 </SelectPopup>2555 </SelectPopup>
2543 </Select>2556 </Select>
2544 {backgroundActivityProfileOption === "advanced" ? (2557 {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? (
2545 <Tooltip>2558 <Tooltip>
2546 <TooltipTrigger2559 <TooltipTrigger
2547 render={2560 render={
...@@ -2559,7 +2572,7 @@ export function GeneralSettingsPanel() {...@@ -2559,7 +2572,7 @@ export function GeneralSettingsPanel() {
2559 </Tooltip>2572 </Tooltip>
2560 ) : null}2573 ) : null}
2561 <BackgroundActivityAdvancedDialog2574 <BackgroundActivityAdvancedDialog
2562 open={backgroundActivityDialogOpen}2575 open={backgroundActivityDialogOpen && isEnvironmentScope}
2563 onOpenChange={setBackgroundActivityDialogOpen}2576 onOpenChange={setBackgroundActivityDialogOpen}
2564 />2577 />
2565 </>2578 </>
...@@ -2568,24 +2581,9 @@ export function GeneralSettingsPanel() {...@@ -2568,24 +2581,9 @@ export function GeneralSettingsPanel() {
2568 </SettingsSection>2581 </SettingsSection>
25692582
2570 <SettingsSection id="projects-and-threads" title="Projects & threads">2583 <SettingsSection id="projects-and-threads" title="Projects & threads">
2571 <SettingsRow
2572 {...searchableSetting("new-threads")}
2573 description="Choose the default model and workspace for all projects or a specific project."
2574 control={
2575 <Button
2576 render={
2577 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
2578 }
2579 size="sm"
2580 variant="outline"
2581 >
2582 Project settings
2583 </Button>
2584 }
2585 />
2586
2587 <SettingsRow2584 <SettingsRow
2588 serverScoped2585 serverScoped
2586 settingKeys={["newWorktreesStartFromOrigin"]}
2589 {...searchableSetting("start-from-origin")}2587 {...searchableSetting("start-from-origin")}
2590 description="Creates the worktree from the latest matching branch on origin instead of your local branch."2588 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
2591 resetAction={2589 resetAction={
...@@ -2603,7 +2601,8 @@ export function GeneralSettingsPanel() {...@@ -2603,7 +2601,8 @@ export function GeneralSettingsPanel() {
2603 ) : null2601 ) : null
2604 }2602 }
2605 control={2603 control={
2606 <Switch2604 <ScopedSwitch
2605 settingKeys={["newWorktreesStartFromOrigin"]}
2607 checked={settings.newWorktreesStartFromOrigin}2606 checked={settings.newWorktreesStartFromOrigin}
2608 onCheckedChange={(checked) =>2607 onCheckedChange={(checked) =>
2609 updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })2608 updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })
...@@ -2614,6 +2613,7 @@ export function GeneralSettingsPanel() {...@@ -2614,6 +2613,7 @@ export function GeneralSettingsPanel() {
2614 />2613 />
2615 <SettingsRow2614 <SettingsRow
2616 serverScoped2615 serverScoped
2616 settingKeys={["addProjectBaseDirectory"]}
2617 {...searchableSetting("add-project-starts-in")}2617 {...searchableSetting("add-project-starts-in")}
2618 description='Leave empty to use "~/" when the Add Project browser opens.'2618 description='Leave empty to use "~/" when the Add Project browser opens.'
2619 resetAction={2619 resetAction={
...@@ -2633,9 +2633,9 @@ export function GeneralSettingsPanel() {...@@ -2633,9 +2633,9 @@ export function GeneralSettingsPanel() {
2633 <DraftInput2633 <DraftInput
2634 size="sm"2634 size="sm"
2635 className="w-full sm:w-72"2635 className="w-full sm:w-72"
2636 value={settings.addProjectBaseDirectory}2636 value={mixedAddProjectBaseDirectory ? "" : settings.addProjectBaseDirectory}
2637 onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}2637 onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}
2638 placeholder="~/"2638 placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"}
2639 spellCheck={false}2639 spellCheck={false}
2640 aria-label="Add project base directory"2640 aria-label="Add project base directory"
2641 />2641 />
...@@ -2768,10 +2768,11 @@ export function GeneralSettingsPanel() {...@@ -2768,10 +2768,11 @@ export function GeneralSettingsPanel() {
2768 <SettingsSection id="text-generation" title="Text generation">2768 <SettingsSection id="text-generation" title="Text generation">
2769 <SettingsRow2769 <SettingsRow
2770 serverScoped2770 serverScoped
2771 settingKeys={["textGenerationModelSelection"]}
2771 {...searchableSetting("text-generation-model")}2772 {...searchableSetting("text-generation-model")}
2772 description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it."2773 description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it."
2773 resetAction={2774 resetAction={
2774 isTextGenerationModelDirty ? (2775 hasServerTargets && isTextGenerationModelDirty ? (
2775 <SettingResetButton2776 <SettingResetButton
2776 label="text generation model"2777 label="text generation model"
2777 onClick={() =>2778 onClick={() =>
...@@ -2784,7 +2785,11 @@ export function GeneralSettingsPanel() {...@@ -2784,7 +2785,11 @@ export function GeneralSettingsPanel() {
2784 ) : null2785 ) : null
2785 }2786 }
2786 control={2787 control={
2787 !hasTextGenerationProvider ? (2788 !hasServerTargets ? (
2789 <span className="text-sm text-muted-foreground">
2790 Connect an environment to choose its text generation model.
2791 </span>
2792 ) : !hasTextGenerationProvider ? (
2788 <span className="text-sm text-muted-foreground">2793 <span className="text-sm text-muted-foreground">
2789 No text generation providers available.2794 No text generation providers available.
2790 </span>2795 </span>
...@@ -2798,6 +2803,8 @@ export function GeneralSettingsPanel() {...@@ -2798,6 +2803,8 @@ export function GeneralSettingsPanel() {
2798 modelOptionsByInstance={textGenerationModelOptionsByInstance}2803 modelOptionsByInstance={textGenerationModelOptionsByInstance}
2799 triggerVariant="outline"2804 triggerVariant="outline"
2800 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}2805 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2806 {...(mixedTextGenerationModel ? { triggerLabel: "Mixed" } : {})}
2807 getModelDisabledReason={textGenerationModelDisabledReason}
2801 {...(environmentId2808 {...(environmentId
2802 ? {2809 ? {
2803 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {2810 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
...@@ -2809,6 +2816,15 @@ export function GeneralSettingsPanel() {...@@ -2809,6 +2816,15 @@ export function GeneralSettingsPanel() {
2809 }2816 }
2810 : {})}2817 : {})}
2811 onInstanceModelChange={(instanceId, model) => {2818 onInstanceModelChange={(instanceId, model) => {
2819 const reason = textGenerationModelDisabledReason(instanceId, model);
2820 if (reason) {
2821 toastManager.add({
2822 type: "error",
2823 title: "Text generation model not saved",
2824 description: reason,
2825 });
2826 return;
2827 }
2812 updateSettings({2828 updateSettings({
2813 textGenerationModelSelection: resolveAppModelSelectionState(2829 textGenerationModelSelection: resolveAppModelSelectionState(
2814 {2830 {
...@@ -2870,11 +2886,23 @@ export function GeneralSettingsPanel() {...@@ -2870,11 +2886,23 @@ export function GeneralSettingsPanel() {
2870 description="Current version of the application."2886 description="Current version of the application."
2871 />2887 />
2872 )}2888 )}
2889 </SettingsSection>
2890 <SettingsSection title="Diagnostics">
2873 <SettingsRow2891 <SettingsRow
2874 {...searchableSetting("diagnostics")}2892 {...searchableSetting("diagnostics")}
2875 description={diagnosticsDescription}2893 description={
2894 isEnvironmentScope
2895 ? "Inspect processes, resource use, and logs on this environment."
2896 : "Inspect processes, resource use, and logs on one environment at a time."
2897 }
2876 control={2898 control={
2877 <Button render={<Link to="/settings/diagnostics" />} size="sm" variant="outline">2899 <Button
2900 render={
2901 <Link to="/settings/diagnostics" search={{ machine: environmentId ?? undefined }} />
2902 }
2903 size="sm"
2904 variant="outline"
2905 >
2878 View diagnostics2906 View diagnostics
2879 </Button>2907 </Button>
2880 }2908 }
...@@ -2887,25 +2915,31 @@ export function GeneralSettingsPanel() {...@@ -2887,25 +2915,31 @@ export function GeneralSettingsPanel() {
2887}2915}
28882916
2889export function ArchivedThreadsPanel() {2917export function ArchivedThreadsPanel() {
2890 const projects = useProjects();2918 const { scope } = useSettingsScope();
2891 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();2919 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
2892 const environmentIds = useMemo(
2893 () => [...new Set(projects.map((project) => project.environmentId))],
2894 [projects],
2895 );
2896 const {2920 const {
2897 snapshots: archivedSnapshots,2921 snapshots: archivedSnapshots,
2898 error: archiveError,2922 error: archiveError,
2899 isLoading: isLoadingArchive,2923 isLoading: isLoadingArchive,
2900 refresh: refreshArchivedThreads,2924 refresh: refreshArchivedThreads,
2901 } = useArchivedThreadSnapshots(environmentIds);2925 } = useArchivedThreadSnapshots(scope.environmentIds);
29022926
2903 const archivedGroups = useMemo(() => {2927 const archivedGroups = useMemo(() => {
2928 const selectedProjectKeys =
2929 scope.kind === "project" || scope.kind === "checkout"
2930 ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`))
2931 : null;
2904 const projectsByEnvironmentAndId = new Map(2932 const projectsByEnvironmentAndId = new Map(
2905 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>2933 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
2906 snapshot.projects.map(2934 snapshot.projects
2907 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,2935 .filter(
2908 ),2936 (project) =>
2937 selectedProjectKeys === null ||
2938 selectedProjectKeys.has(`${environmentId}:${project.id}`),
2939 )
2940 .map(
2941 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
2942 ),
2909 ),2943 ),
2910 );2944 );
2911 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>2945 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
...@@ -2939,7 +2973,7 @@ export function ArchivedThreadsPanel() {...@@ -2939,7 +2973,7 @@ export function ArchivedThreadsPanel() {
2939 }2973 }
2940 }2974 }
2941 return groups;2975 return groups;
2942 }, [archivedSnapshots]);2976 }, [archivedSnapshots, scope]);
29432977
2944 const handleArchivedThreadContextMenu = useCallback(2978 const handleArchivedThreadContextMenu = useCallback(
2945 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {2979 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
...@@ -3021,7 +3055,7 @@ export function ArchivedThreadsPanel() {...@@ -3021,7 +3055,7 @@ export function ArchivedThreadsPanel() {
3021 ) : (3055 ) : (
3022 archivedGroups.map(({ project, threads: projectThreads }, index) => (3056 archivedGroups.map(({ project, threads: projectThreads }, index) => (
3023 <SettingsSection3057 <SettingsSection
3024 key={project.id}3058 key={`${project.environmentId}:${project.id}`}
3025 id={index === 0 ? searchableSetting("archive").id : undefined}3059 id={index === 0 ? searchableSetting("archive").id : undefined}
3026 title={project.title}3060 title={project.title}
3027 icon={<ProjectFavicon project={project} />}3061 icon={<ProjectFavicon project={project} />}
apps/web/src/components/settings/SettingsScopeContext.tsx created+65
...@@ -0,0 +1,65 @@
1import { createContext, type ReactNode, useContext, useMemo } from "react";
2
3import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
4import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
5import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
6import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
7
8function useResolvedSettingsScope(search: SettingsScopeSearch) {
9 const groups = useSettingsProjectGroups();
10 const { environments: availableEnvironments } = useEnvironments();
11 const primaryEnvironmentId = usePrimaryEnvironmentId();
12 return useMemo(() => {
13 const scope = resolveSettingsScope(search, groups, availableEnvironments);
14 const selected = selectScopedSettingsEnvironments(
15 scope,
16 availableEnvironments,
17 primaryEnvironmentId,
18 );
19 const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments);
20 // The representative target supplies display values; project scopes
21 // prefer the member on the primary environment, like environments do.
22 const target =
23 targets.find(
24 (candidate) => candidate.environmentId === selected.environment?.environmentId,
25 ) ??
26 targets[0] ??
27 null;
28 return { scope, groups, ...selected, targets, target };
29 }, [availableEnvironments, groups, primaryEnvironmentId, search]);
30}
31
32const SettingsScopeContext = createContext<
33 | (ReturnType<typeof useResolvedSettingsScope> & {
34 search: SettingsScopeSearch;
35 selectScope: (next: SettingsScopeSearch) => void;
36 })
37 | null
38>(null);
39
40export function SettingsScopeProvider({
41 search,
42 onChange,
43 children,
44}: {
45 search: SettingsScopeSearch;
46 onChange: (next: SettingsScopeSearch) => void;
47 children: ReactNode;
48}) {
49 const resolved = useResolvedSettingsScope(search);
50 const value = useMemo(
51 () => ({ ...resolved, search, selectScope: onChange }),
52 [onChange, resolved, search],
53 );
54 return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>;
55}
56
57export function useOptionalSettingsScope() {
58 return useContext(SettingsScopeContext);
59}
60
61export function useSettingsScope() {
62 const scope = useOptionalSettingsScope();
63 if (scope === null) throw new Error("Settings scope must be read inside SettingsScopeProvider.");
64 return scope;
65}
apps/web/src/components/settings/SettingsScopeNotice.tsx created+90
...@@ -0,0 +1,90 @@
1import { Button } from "../ui/button";
2import { Alert, AlertAction, AlertDescription } from "../ui/alert";
3import { SettingsPageContainer } from "./settingsLayout";
4import { useSettingsScope } from "./SettingsScopeContext";
5import { useEnvironments } from "../../state/environments";
6import type { SettingsScopeSearch } from "./settingsScope";
7import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
8import { useLocation, useNavigate } from "@tanstack/react-router";
9import type { EnvironmentId } from "@t3tools/contracts";
10
11/** Offer an explicit target change when a category has no settings at this scope. */
12export function SettingsScopeNotice({
13 children,
14 target,
15 targetId,
16 eligibleEnvironmentIds,
17}: {
18 children: string;
19 target: "environment" | "all" | "project" | "checkout";
20 targetId?: string;
21 eligibleEnvironmentIds?: readonly EnvironmentId[];
22}) {
23 const { selectScope, search } = useSettingsScope();
24 const navigate = useNavigate({ from: "/settings" });
25 const pathname = useLocation({ select: (location) => location.pathname });
26 const { environments } = useEnvironments();
27 const groups = useSettingsProjectGroups();
28 const choices: { label: string; search: SettingsScopeSearch }[] =
29 target === "checkout"
30 ? groups
31 .filter((group) => !search.project || group.projectKey === search.project)
32 .flatMap((group) =>
33 group.memberProjects.map((member) => ({
34 label: `${group.displayName} · ${member.environmentLabel ?? "Environment"} · ${member.workspaceRoot}`,
35 search: {
36 project: group.projectKey,
37 machine: member.environmentId,
38 checkout: member.physicalProjectKey,
39 },
40 })),
41 )
42 : target === "project"
43 ? groups.map((group) => ({
44 label: group.displayName,
45 search: { project: group.projectKey },
46 }))
47 : target === "environment"
48 ? environments
49 .filter(
50 (entry) =>
51 eligibleEnvironmentIds === undefined ||
52 eligibleEnvironmentIds.includes(entry.environmentId),
53 )
54 .map((entry) => ({
55 label: environments.some(
56 (other) =>
57 other.environmentId !== entry.environmentId && other.label === entry.label,
58 )
59 ? `${entry.label} · ${entry.displayUrl || entry.environmentId}`
60 : entry.label,
61 search: { machine: entry.environmentId },
62 }))
63 : [{ label: "Open all environments", search: {} }];
64 return (
65 <SettingsPageContainer>
66 <Alert role="status">
67 <AlertDescription>
68 <p>{children}</p>
69 <AlertAction className="flex-wrap gap-2">
70 {choices.map((choice) => (
71 <Button
72 key={JSON.stringify(choice.search)}
73 size="sm-multiline"
74 variant="outline"
75 className="max-w-full break-all text-left"
76 onClick={() => {
77 if (targetId)
78 void navigate({ to: pathname, search: () => choice.search, hash: targetId });
79 else selectScope(choice.search);
80 }}
81 >
82 {choice.label}
83 </Button>
84 ))}
85 </AlertAction>
86 </AlertDescription>
87 </Alert>
88 </SettingsPageContainer>
89 );
90}
apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts deleted-88
...@@ -1,88 +0,0 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 environmentAxisValue,
6 projectAxisValue,
7 selectEnvironmentAxis,
8 selectProjectAxis,
9 settingsScopeEnvironmentLabel,
10} from "./SettingsScopeSelects.logic";
11
12const first = {
13 environmentId: EnvironmentId.make("first"),
14 label: "Development",
15 displayUrl: "https://first.example.com",
16};
17const second = {
18 environmentId: EnvironmentId.make("second"),
19 label: "Development",
20 displayUrl: "https://second.example.com",
21};
22
23describe("settings scope environment labels", () => {
24 it("distinguishes same-name environments by address", () => {
25 const environments = [first, second];
26 expect(
27 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
28 ).toEqual([
29 "Development · https://first.example.com",
30 "Development · https://second.example.com",
31 ]);
32 });
33
34 it("falls back to environment IDs when duplicate names have no display URL", () => {
35 const environments = [first, second].map((environment) => ({
36 ...environment,
37 displayUrl: null,
38 }));
39 expect(
40 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
41 ).toEqual(["Development · first", "Development · second"]);
42 });
43
44 it("keeps unique names compact and removes disambiguation after a rename", () => {
45 expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development");
46 expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe(
47 "Development",
48 );
49 });
50});
51
52describe("settings scope axes", () => {
53 it("maps each axis to its search key and back", () => {
54 expect(projectAxisValue({})).toBe("all");
55 expect(projectAxisValue({ project: "app" })).toBe("app");
56 expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({
57 project: "app",
58 machine: "second",
59 });
60 expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({
61 machine: "second",
62 });
63 expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({
64 project: "app",
65 machine: "first",
66 });
67 expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({
68 project: "app",
69 });
70 });
71
72 it("drops a checkout narrowing from older links when either axis changes", () => {
73 const checkout = { project: "app", checkout: "app@first", machine: "first" };
74 expect(selectEnvironmentAxis(checkout, "second")).toEqual({
75 project: "app",
76 machine: "second",
77 });
78 expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" });
79 });
80});
81
82describe("environmentAxisValue", () => {
83 it("shows the checkout's environment for a legacy checkout link", () => {
84 expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop");
85 expect(environmentAxisValue({ project: "p" }, null)).toBe("all");
86 expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk");
87 });
88});
apps/web/src/components/settings/SettingsScopeSelects.logic.ts deleted-55
...@@ -1,55 +0,0 @@
1import type { EnvironmentPresentation } from "../../state/environments";
2import type { SettingsScopeSearch } from "./settingsScope";
3
4type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;
5
6export function settingsScopeEnvironmentLabel(
7 environment: ScopeEnvironment,
8 environments: readonly ScopeEnvironment[],
9) {
10 const duplicate = environments.some(
11 (other) =>
12 other.environmentId !== environment.environmentId && other.label === environment.label,
13 );
14 return duplicate
15 ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}`
16 : environment.label;
17}
18
19export const ALL_ENVIRONMENTS_VALUE = "all";
20export const ALL_PROJECTS_VALUE = "all";
21
22/**
23 * The environment axis: `all` or an environment id. A legacy checkout link
24 * without `machine` still names one environment, which the resolver supplies.
25 */
26export function environmentAxisValue(
27 search: SettingsScopeSearch,
28 resolvedEnvironmentId?: string | null,
29): string {
30 return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE;
31}
32
33/** The project axis: `all` or a project key. */
34export function projectAxisValue(search: SettingsScopeSearch): string {
35 return search.project ?? ALL_PROJECTS_VALUE;
36}
37
38/** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */
39export function selectEnvironmentAxis(
40 search: SettingsScopeSearch,
41 value: string,
42): SettingsScopeSearch {
43 const next: SettingsScopeSearch = {};
44 if (search.project) next.project = search.project;
45 if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value;
46 return next;
47}
48
49/** Choosing a project keeps the environment axis. */
50export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch {
51 const next: SettingsScopeSearch = {};
52 if (value !== ALL_PROJECTS_VALUE) next.project = value;
53 if (search.machine) next.machine = search.machine;
54 return next;
55}
apps/web/src/components/settings/SettingsScopeSelects.tsx deleted-154
...@@ -1,154 +0,0 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { LayersIcon } from "lucide-react";
3
4import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
5import type { EnvironmentPresentation } from "../../state/environments";
6import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
7import { ProjectFavicon } from "../ProjectFavicon";
8import {
9 Select,
10 SelectGroup,
11 SelectGroupLabel,
12 SelectItem,
13 SelectPopup,
14 SelectTrigger,
15 SelectValue,
16} from "../ui/select";
17import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
18import {
19 ALL_ENVIRONMENTS_VALUE,
20 ALL_PROJECTS_VALUE,
21 environmentAxisValue,
22 projectAxisValue,
23 selectEnvironmentAxis,
24 selectProjectAxis,
25 settingsScopeEnvironmentLabel,
26} from "./SettingsScopeSelects.logic";
27
28const TRIGGER_CLASSNAME = "w-auto min-w-0 max-w-56 justify-start";
29
30/**
31 * Two independent targets: which environments a change applies to, and
32 * which project it overrides. A project is the same project on every
33 * environment; the environment select alone decides where the override is
34 * written. Each axis defaults to "all".
35 */
36export function SettingsScopeSelects({
37 value,
38 groups,
39 environments,
40 onChange,
41}: {
42 value: SettingsScopeSearch;
43 groups: readonly SidebarProjectSnapshot[];
44 environments: readonly EnvironmentPresentation[];
45 onChange: (next: SettingsScopeSearch) => void;
46}) {
47 const resolved = resolveSettingsScope(value, groups, environments);
48 const environmentValue = environmentAxisValue(
49 value,
50 resolved.kind === "checkout" ? resolved.environmentId : null,
51 );
52 const projectValue = projectAxisValue(value);
53 const selectedEnvironment = environments.find(
54 (environment) => environment.environmentId === environmentValue,
55 );
56 const selectedGroup = groups.find((group) => group.projectKey === value.project);
57
58 return (
59 <div className="flex min-w-0 items-center gap-1.5" role="group" aria-label="Settings scope">
60 <Select
61 value={environmentValue}
62 onValueChange={(next) => {
63 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
64 }}
65 >
66 <SelectTrigger size="compact" aria-label="Environment scope" className={TRIGGER_CLASSNAME}>
67 <SelectValue>
68 <span className="flex min-w-0 items-center gap-1.5">
69 {selectedEnvironment ? (
70 <EnvironmentMachineIcon
71 aria-hidden
72 kind={resolveEnvironmentMachineKind(selectedEnvironment.serverConfig)}
73 className="size-3.5"
74 />
75 ) : (
76 <LayersIcon aria-hidden className="size-3.5" />
77 )}
78 <span className="truncate">
79 {selectedEnvironment
80 ? settingsScopeEnvironmentLabel(selectedEnvironment, environments)
81 : environmentValue !== ALL_ENVIRONMENTS_VALUE
82 ? "Unavailable environment"
83 : "All environments"}
84 </span>
85 </span>
86 </SelectValue>
87 </SelectTrigger>
88 <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56">
89 <SelectItem value={ALL_ENVIRONMENTS_VALUE}>
90 <span className="flex items-center gap-2">
91 <LayersIcon aria-hidden className="size-3.5" />
92 All environments
93 </span>
94 </SelectItem>
95 <SelectGroup>
96 <SelectGroupLabel>Environments</SelectGroupLabel>
97 {environments.map((environment) => (
98 <SelectItem key={environment.environmentId} value={environment.environmentId}>
99 <span className="flex w-full min-w-0 items-center gap-2">
100 <EnvironmentMachineIcon
101 aria-hidden
102 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
103 className="size-3.5"
104 />
105 <span className="min-w-0 flex-1 truncate">
106 {settingsScopeEnvironmentLabel(environment, environments)}
107 </span>
108 {environment.connection.phase === "connected" ? null : (
109 <span className="text-xs text-muted-foreground">Offline</span>
110 )}
111 </span>
112 </SelectItem>
113 ))}
114 </SelectGroup>
115 </SelectPopup>
116 </Select>
117
118 <Select
119 value={projectValue}
120 onValueChange={(next) => {
121 if (typeof next === "string") onChange(selectProjectAxis(value, next));
122 }}
123 >
124 <SelectTrigger size="compact" aria-label="Project scope" className={TRIGGER_CLASSNAME}>
125 <SelectValue>
126 <span className="flex min-w-0 items-center gap-1.5">
127 {selectedGroup ? (
128 <ProjectFavicon project={selectedGroup} className="size-3.5" />
129 ) : null}
130 <span className="truncate">
131 {selectedGroup?.displayName ??
132 (value.project ? "Unavailable project" : "All projects")}
133 </span>
134 </span>
135 </SelectValue>
136 </SelectTrigger>
137 <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56">
138 <SelectItem value={ALL_PROJECTS_VALUE}>All projects</SelectItem>
139 {groups.map((group) => (
140 <SelectItem key={group.projectKey} value={group.projectKey}>
141 <span className="flex w-full min-w-0 items-center gap-2">
142 <ProjectFavicon project={group} className="size-3.5" />
143 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
144 </span>
145 </SelectItem>
146 ))}
147 </SelectPopup>
148 </Select>
149 {resolved.kind === "unavailable" ? (
150 <span className="truncate text-xs text-muted-foreground">{resolved.message}</span>
151 ) : null}
152 </div>
153 );
154}
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-8
...@@ -41,11 +41,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";...@@ -41,11 +41,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
41import { scrollToSettingsTarget } from "./settingsLayout";41import { scrollToSettingsTarget } from "./settingsLayout";
42import {42import {
43 searchSettings,43 searchSettings,
44 isSettingsOverviewVisible,
44 SETTINGS_SECTION_LABELS,45 SETTINGS_SECTION_LABELS,
45 type SettingsPath,46 type SettingsPath,
46 type SettingsSearchItem,47 type SettingsSearchItem,
47} from "./settingsSearch";48} from "./settingsSearch";
48import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";49import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
50import { validateSettingsScopeSearch } from "./settingsScope";
4951
50const SnapShotIcon = createLucideIcon("snap-shot", [52const SnapShotIcon = createLucideIcon("snap-shot", [
51 [53 [
...@@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {...@@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
103export function SettingsSidebarNav({ pathname }: { pathname: string }) {105export function SettingsSidebarNav({ pathname }: { pathname: string }) {
104 const navigate = useNavigate();106 const navigate = useNavigate();
105 const currentHash = useLocation({ select: (location) => location.hash });107 const currentHash = useLocation({ select: (location) => location.hash });
108 const currentSearch = useLocation({ select: (location) => location.search });
109 const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);
110 const navItems = SETTINGS_NAV_ITEMS.filter(
111 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
112 );
106 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();113 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
107 const searchInputRef = useRef<HTMLInputElement>(null);114 const searchInputRef = useRef<HTMLInputElement>(null);
108 const [query, setQuery] = useState("");115 const [query, setQuery] = useState("");
...@@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
181 setOpenMobile(false);188 setOpenMobile(false);
182 }189 }
183 const targetId = item.targetId ?? item.id;190 const targetId = item.targetId ?? item.id;
184 if (191 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
185 item.to !== "/settings/projects" &&
186 pathname === item.to &&
187 currentHash.replace(/^#/, "") === targetId
188 ) {
189 scrollToSettingsTarget(targetId);192 scrollToSettingsTarget(targetId);
190 return;193 return;
191 }194 }
192 void navigate({195 void navigate({
193 to: item.to,196 to: item.to,
194 search: (previous) =>
195 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
196 hash: targetId,197 hash: targetId,
197 replace: true,198 replace: true,
198 hashScrollIntoView: false,199 hashScrollIntoView: false,
...@@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
319 </SidebarMenu>320 </SidebarMenu>
320 ) : (321 ) : (
321 <SidebarMenu className="ps-px">322 <SidebarMenu className="ps-px">
322 {SETTINGS_NAV_ITEMS.map((item) => {323 {navItems.map((item) => {
323 const Icon = item.icon;324 const Icon = item.icon;
324 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);325 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
325 return (326 return (
apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx deleted-32
...@@ -1,32 +0,0 @@
1import { TriangleAlertIcon } from "lucide-react";
2
3import { useSharedSettingsSync } from "../../hooks/useSettings";
4import { Alert, AlertAction, AlertDescription } from "../ui/alert";
5import { Button } from "../ui/button";
6
7/**
8 * Warns when a connected environment holds different shared settings than
9 * the primary one, and offers to write the primary's values everywhere.
10 * Renders nothing when every connected environment agrees.
11 */
12export function SharedSettingsMismatchAlert() {
13 const { mismatches, applyToAll } = useSharedSettingsSync();
14 if (mismatches.length === 0) {
15 return null;
16 }
17 const labels = mismatches.map((mismatch) => mismatch.label).join(", ");
18 return (
19 <Alert variant="warning" className="mx-3 sm:mx-4">
20 <TriangleAlertIcon />
21 <AlertDescription>
22 Settings differ on {labels}. Thread and source control preferences are meant to match on
23 every environment.
24 </AlertDescription>
25 <AlertAction>
26 <Button variant="outline" size="xs" onClick={applyToAll}>
27 Apply to all
28 </Button>
29 </AlertAction>
30 </Alert>
31 );
32}
apps/web/src/components/settings/SnapShotSettings.test.tsx+1
...@@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => {...@@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => {
26});26});
27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));28vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
29vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
29const bridge = vi.hoisted(() => ({30const bridge = vi.hoisted(() => ({
30 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),31 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),
31 setSnapShotShortcutSuppressed: vi.fn(),32 setSnapShotShortcutSuppressed: vi.fn(),
apps/web/src/components/settings/SourceControlSettings.tsx+31-23
...@@ -18,10 +18,10 @@ import {...@@ -18,10 +18,10 @@ import {
18 resolveServerBackgroundActivitySettings,18 resolveServerBackgroundActivitySettings,
19} from "@t3tools/shared/backgroundActivitySettings";19} from "@t3tools/shared/backgroundActivitySettings";
2020
21import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";21import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
22import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";22import { useSettingsScope } from "./SettingsScopeContext";
23import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
23import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
24import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
25import { useEnvironmentQuery } from "../../state/query";25import { useEnvironmentQuery } from "../../state/query";
26import { sourceControlEnvironment } from "../../state/sourceControl";26import { sourceControlEnvironment } from "../../state/sourceControl";
27import { Badge } from "../ui/badge";27import { Badge } from "../ui/badge";
...@@ -343,8 +343,8 @@ function DiscoveryItemRow({...@@ -343,8 +343,8 @@ function DiscoveryItemRow({
343}343}
344344
345function GitFetchIntervalSettings() {345function GitFetchIntervalSettings() {
346 const settings = usePrimarySettings();346 const settings = useScopedSettings();
347 const updateSettings = useUpdatePrimarySettings();347 const updateSettings = useUpdateScopedSettings();
348 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);348 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
349 const automaticGitFetchIntervalSeconds = durationToSeconds(349 const automaticGitFetchIntervalSeconds = durationToSeconds(
350 resolvedBackgroundActivity.automaticGitFetchInterval,350 resolvedBackgroundActivity.automaticGitFetchInterval,
...@@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({...@@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({
498}498}
499499
500export function SourceControlSettingsPanel() {500export function SourceControlSettingsPanel() {
501 const { environments } = useEnvironments();501 const { scope, environment, connectedEnvironments } = useSettingsScope();
502 const primaryEnvironment = usePrimaryEnvironment();502 // Discovery scans one machine's tools, so it shows the representative
503 const fallbackEnvironment =503 // environment (named in the section title when several are selected);
504 environments.find((environment) => environment.connection.phase === "connected") ??504 // the settings rows above it fan out like everywhere else.
505 environments[0] ??
506 null;
507 const environmentId =505 const environmentId =
508 primaryEnvironment?.environmentId ?? fallbackEnvironment?.environmentId ?? null;506 environment?.connection.phase === "connected" ? environment.environmentId : null;
509 const isPrimaryEnvironment = environmentId === primaryEnvironment?.environmentId;507 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
508 const environmentSuffix = aggregate && environment ? ` · ${environment.label}` : "";
510 const discovery = useEnvironmentQuery(509 const discovery = useEnvironmentQuery(
511 environmentId === null510 environmentId === null
512 ? null511 ? null
...@@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() {...@@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() {
543542
544 return (543 return (
545 <SettingsPageContainer>544 <SettingsPageContainer>
546 <SharedSettingsMismatchAlert />545 <ProjectDefaultsSettings category="source-control" />
547 {isInitialScanPending ? (546 {environmentId === null ? (
547 <SettingsSection id={searchableSetting("source-control").id} title="Server environment">
548 <p className="px-4 py-3 text-sm text-muted-foreground">
549 Connect an environment to inspect its version control tools and hosting integrations.
550 </p>
551 </SettingsSection>
552 ) : isInitialScanPending ? (
548 <>553 <>
549 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />554 <SourceControlSectionSkeleton
555 title={`Version Control${environmentSuffix}`}
556 headerAction={scanButton}
557 />
550 <SourceControlSectionSkeleton title="Source Control Providers" />558 <SourceControlSectionSkeleton title="Source Control Providers" />
551 </>559 </>
552 ) : hasDiscoveryItems ? (560 ) : hasDiscoveryItems ? (
...@@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() {...@@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() {
554 {hasVersionControlSystems ? (562 {hasVersionControlSystems ? (
555 <SettingsSection563 <SettingsSection
556 id={searchableSetting("source-control").id}564 id={searchableSetting("source-control").id}
557 title="Version Control"565 title={`Version Control${environmentSuffix}`}
558 headerAction={scanButton}566 headerAction={scanButton}
559 >567 >
560 {result.versionControlSystems.map((item) => (568 {result.versionControlSystems.map((item) => (
561 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>569 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>
562 {item.kind === "git" && isPrimaryEnvironment ? (570 {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined}
563 <GitFetchIntervalSettings />
564 ) : undefined}
565 </DiscoveryItemRow>571 </DiscoveryItemRow>
566 ))}572 ))}
567 </SettingsSection>573 </SettingsSection>
...@@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() {...@@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() {
570 {result.sourceControlProviders.length > 0 ? (576 {result.sourceControlProviders.length > 0 ? (
571 <SettingsSection577 <SettingsSection
572 id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id}578 id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id}
573 title="Source Control Providers"579 title={
580 hasVersionControlSystems
581 ? "Source Control Providers"
582 : `Source Control Providers${environmentSuffix}`
583 }
574 headerAction={hasVersionControlSystems ? null : scanButton}584 headerAction={hasVersionControlSystems ? null : scanButton}
575 >585 >
576 {result.sourceControlProviders.map((item) => (586 {result.sourceControlProviders.map((item) => (
...@@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() {...@@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() {
587 />597 />
588 )}598 )}
589599
590 {/* Its rows are serverScoped: without a primary they render inert with
591 an explanation, which beats disappearing. */}
592 <SourceControlWritingSettingsSection />600 <SourceControlWritingSettingsSection />
593 </SettingsPageContainer>601 </SettingsPageContainer>
594 );602 );
apps/web/src/components/settings/SourceControlWritingSettings.test.tsx created+221
...@@ -0,0 +1,221 @@
1import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2import { act, StrictMode, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import type { ScopedSettingsPatch } from "./scopedSettings";
7
8type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
9const state = vi.hoisted(() => ({
10 styles: [] as WritingStyle[],
11 updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(),
12}));
13
14vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
15vi.mock("./useScopedSettings", () => ({
16 useScopedSettings: () => ({
17 ...DEFAULT_UNIFIED_SETTINGS,
18 sourceControlWritingStyle: state.styles[0],
19 }),
20 useScopedSettingsMixed: () => JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]),
21 useUpdateScopedSettings: () => state.updateSettings,
22}));
23vi.mock("./SettingsScopeContext", () => ({
24 useSettingsScope: () => ({
25 scope: { kind: "all", environmentIds: [] },
26 environment: null,
27 connectedEnvironments: [],
28 targets: state.styles.map((style) => ({
29 settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style },
30 })),
31 }),
32}));
33vi.mock("./useScopedModelAvailability", () => ({
34 useScopedModelDisabledReason: () => () => null,
35}));
36vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
37vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] }));
38vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null }));
39vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) }));
40vi.mock("./settingsLayout", () => ({
41 SETTINGS_PICKER_TRIGGER_CLASSNAME: "",
42 SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => (
43 <button onClick={onClick}>{`Reset ${label}`}</button>
44 ),
45 SettingsSection: ({ children }: { children: ReactNode }) => children,
46 SettingsRow: ({
47 children,
48 control,
49 resetAction,
50 }: {
51 children: ReactNode;
52 control: ReactNode;
53 resetAction: ReactNode;
54 }) => (
55 <div>
56 {control}
57 {resetAction}
58 {children}
59 </div>
60 ),
61}));
62vi.mock("../ui/select", () => ({
63 Select: ({ children }: { children: ReactNode }) => children,
64 SelectItem: "span",
65 SelectPopup: "div",
66 SelectTrigger: "div",
67 SelectValue: "span",
68}));
69vi.mock("../ui/switch", () => ({ Switch: "input" }));
70vi.mock("../ui/textarea", () => ({ Textarea: "textarea" }));
71vi.mock("../ui/button", () => ({ Button: "button" }));
72
73import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
74
75let renderer: ReactTestRenderer | null;
76
77function button(label: string) {
78 return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!;
79}
80
81function openEditor() {
82 act(() => button("Write custom instructions for all").props.onClick());
83}
84
85function editInstructions(value: string) {
86 act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } }));
87}
88
89function applyInstructions() {
90 act(() => button("Apply instructions to all").props.onClick());
91}
92
93beforeEach(() => {
94 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
95 state.styles = [
96 {
97 ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
98 mode: "custom",
99 customInstructions: "First environment instructions",
100 followChangeRequestTemplates: true,
101 },
102 {
103 ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
104 mode: "custom",
105 customInstructions: "Second environment instructions",
106 followChangeRequestTemplates: false,
107 },
108 ];
109 state.updateSettings.mockReset().mockImplementation((patch) => {
110 state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle }));
111 });
112 act(() => {
113 renderer = create(
114 <StrictMode>
115 <SourceControlWritingSettingsSection />
116 </StrictMode>,
117 );
118 });
119});
120
121afterEach(async () => {
122 await act(async () => renderer?.unmount());
123 renderer = null;
124 vi.unstubAllGlobals();
125});
126
127describe("mixed source control instructions", () => {
128 it("resets mixed template preferences without replacing each environment's instructions", () => {
129 const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({
130 mode,
131 customInstructions,
132 }));
133
134 act(() => button("Reset change request templates").props.onClick());
135
136 expect(state.updateSettings).toHaveBeenCalledTimes(1);
137 expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([
138 DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
139 DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
140 ]);
141 expect(
142 state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
143 ).toEqual(initialInstructions);
144 });
145
146 it("resets every environment even when the representative already has default instructions", () => {
147 state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle };
148 act(() => {
149 renderer!.update(
150 <StrictMode>
151 <SourceControlWritingSettingsSection />
152 </StrictMode>,
153 );
154 });
155
156 act(() => button("Reset source control writing style").props.onClick());
157
158 expect(state.updateSettings).toHaveBeenCalledTimes(1);
159 expect(
160 state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
161 ).toEqual(
162 [0, 1].map(() => ({
163 mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode,
164 customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions,
165 })),
166 );
167 expect(state.styles[1]!.followChangeRequestTemplates).toBe(false);
168 });
169
170 it("does not write an untouched bulk draft", () => {
171 const initialStyles = state.styles;
172 openEditor();
173 expect(renderer!.root.findByType("textarea").props.value).toBe("");
174 expect(button("Apply instructions to all").props.disabled).toBe(true);
175
176 applyInstructions();
177 expect(state.updateSettings).not.toHaveBeenCalled();
178 expect(state.styles).toEqual(initialStyles);
179 });
180
181 it("applies edited instructions to every selected environment", () => {
182 openEditor();
183 editInstructions(" Keep titles concise. ");
184 expect(button("Apply instructions to all").props.disabled).toBe(false);
185 applyInstructions();
186
187 expect(state.updateSettings).toHaveBeenCalledTimes(1);
188 expect(state.styles.map((style) => style.customInstructions)).toEqual([
189 "Keep titles concise.",
190 "Keep titles concise.",
191 ]);
192 expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]);
193 });
194
195 it("allows an intentional clear after editing", () => {
196 openEditor();
197 editInstructions("Temporary instructions");
198 editInstructions("");
199 expect(button("Apply instructions to all").props.disabled).toBe(false);
200 applyInstructions();
201
202 expect(state.updateSettings).toHaveBeenCalledTimes(1);
203 expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]);
204 });
205
206 it("shows the plain editor once instructions agree, even while templates differ", () => {
207 openEditor();
208 editInstructions("Shared instructions");
209 applyInstructions();
210
211 expect(state.updateSettings).toHaveBeenCalledTimes(1);
212 expect(state.styles.map((style) => style.customInstructions)).toEqual([
213 "Shared instructions",
214 "Shared instructions",
215 ]);
216 // Template preferences still differ, but that is the templates row's
217 // concern: the instructions editor is no longer a bulk draft.
218 expect(button("Write custom instructions for all")).toBeUndefined();
219 expect(renderer!.root.findByType("textarea").props.defaultValue).toBe("Shared instructions");
220 });
221});
apps/web/src/components/settings/SourceControlWritingSettings.tsx+169-63
...@@ -1,12 +1,21 @@...@@ -1,12 +1,21 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";1import { useNavigate } from "@tanstack/react-router";
3import { useRef } from "react";2import { useRef, useState } from "react";
4import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts";3import type {
4 ProviderInstanceId,
5 ServerSettings,
6 SourceControlWritingStyleMode,
7} from "@t3tools/contracts";
5import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
6import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
7import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";10import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
811
9import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";12import {
13 useScopedSettings,
14 useScopedSettingsMixed,
15 useUpdateScopedSettings,
16} from "./useScopedSettings";
17import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
18import { useSettingsScope } from "./SettingsScopeContext";
10import {19import {
11 applyProviderInstanceSettings,20 applyProviderInstanceSettings,
12 deriveProviderInstanceEntries,21 deriveProviderInstanceEntries,
...@@ -16,12 +25,13 @@ import {...@@ -16,12 +25,13 @@ import {
16 getCustomModelOptionsByInstance,25 getCustomModelOptionsByInstance,
17 resolveAppModelSelectionState,26 resolveAppModelSelectionState,
18} from "../../modelSelection";27} from "../../modelSelection";
19import { primaryServerProvidersAtom } from "../../state/server";28import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
20import { usePrimaryEnvironmentId } from "../../state/environments";
21import { ProviderModelPicker } from "../chat/ProviderModelPicker";29import { ProviderModelPicker } from "../chat/ProviderModelPicker";
22import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";30import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
23import { Switch } from "../ui/switch";31import { Switch } from "../ui/switch";
24import { Textarea } from "../ui/textarea";32import { Textarea } from "../ui/textarea";
33import { toastManager } from "../ui/toast";
34import { Button } from "../ui/button";
25import {35import {
26 SETTINGS_PICKER_TRIGGER_CLASSNAME,36 SETTINGS_PICKER_TRIGGER_CLASSNAME,
27 SettingResetButton,37 SettingResetButton,
...@@ -48,16 +58,41 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr...@@ -48,16 +58,41 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr
48 };58 };
4959
50export function SourceControlWritingSettingsSection() {60export function SourceControlWritingSettingsSection() {
51 const settings = usePrimarySettings();61 const settings = useScopedSettings();
52 const updateSettings = useUpdatePrimarySettings();62 const updateSettings = useUpdateScopedSettings();
53 const navigate = useNavigate();63 const navigate = useNavigate();
54 const environmentId = usePrimaryEnvironmentId();64 const { environment, connectedEnvironments, targets } = useSettingsScope();
55 const serverProviders = useAtomValue(primaryServerProvidersAtom);65 // The representative supplies the provider list; a model choice is checked
66 // against every target before it fans out.
67 const environmentId = environment?.environmentId ?? null;
68 const hasServerTargets = connectedEnvironments.length > 0;
69 const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
70 // The writing style is one object; each control only cares about its own field.
71 const styleFieldMixed = (field: keyof ServerSettings["sourceControlWritingStyle"]) => {
72 const first = targets[0];
73 return (
74 first !== undefined &&
75 targets.some(
76 (candidate) =>
77 candidate.settings.sourceControlWritingStyle[field] !==
78 first.settings.sourceControlWritingStyle[field],
79 )
80 );
81 };
82 const modeMixed = styleFieldMixed("mode");
83 const instructionsMixed = styleFieldMixed("customInstructions");
84 const templatesMixed = styleFieldMixed("followChangeRequestTemplates");
85 const writingStyleMixed = modeMixed || instructionsMixed;
86 const mixedWriterModel = useScopedSettingsMixed(["sourceControlWriterModelSelection"]);
56 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);87 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
88 const [editingAllInstructions, setEditingAllInstructions] = useState(false);
89 const [allInstructions, setAllInstructions] = useState<string | null>(null);
57 const style = settings.sourceControlWritingStyle;90 const style = settings.sourceControlWritingStyle;
58 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;91 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
59 const isSourceControlWritingStyleDirty =92 const isSourceControlWritingStyleDirty =
60 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;93 writingStyleMixed ||
94 style.mode !== defaults.mode ||
95 style.customInstructions !== defaults.customInstructions;
6196
62 const textGenerationProviders = serverProviders.filter(97 const textGenerationProviders = serverProviders.filter(
63 (provider) => provider.supportsTextGeneration !== false,98 (provider) => provider.supportsTextGeneration !== false,
...@@ -87,11 +122,14 @@ export function SourceControlWritingSettingsSection() {...@@ -87,11 +122,14 @@ export function SourceControlWritingSettingsSection() {
87 activeSelection.instanceId,122 activeSelection.instanceId,
88 activeSelection.model,123 activeSelection.model,
89 );124 );
125 const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
90126
91 return (127 return (
92 <SettingsSection id="source-control-text-generation" title="Text generation">128 <SettingsSection id="source-control-text-generation" title="Text generation">
93 <SettingsRow129 <SettingsRow
94 serverScoped130 serverScoped
131 settingKeys={["sourceControlWritingStyle"]}
132 mixed={writingStyleMixed}
95 {...searchableSetting("source-control-writing-style")}133 {...searchableSetting("source-control-writing-style")}
96 description={MODE_OPTIONS[style.mode].description}134 description={MODE_OPTIONS[style.mode].description}
97 resetAction={135 resetAction={
...@@ -111,7 +149,7 @@ export function SourceControlWritingSettingsSection() {...@@ -111,7 +149,7 @@ export function SourceControlWritingSettingsSection() {
111 }149 }
112 control={150 control={
113 <Select151 <Select
114 value={style.mode}152 value={modeMixed ? null : style.mode}
115 onValueChange={(value) => {153 onValueChange={(value) => {
116 const customInstructions = customInstructionsRef.current?.value.trim();154 const customInstructions = customInstructionsRef.current?.value.trim();
117 updateSettings({155 updateSettings({
...@@ -127,7 +165,11 @@ export function SourceControlWritingSettingsSection() {...@@ -127,7 +165,11 @@ export function SourceControlWritingSettingsSection() {
127 className="w-full sm:w-56"165 className="w-full sm:w-56"
128 aria-label="Source control writing style"166 aria-label="Source control writing style"
129 >167 >
130 <SelectValue>{MODE_OPTIONS[style.mode].label}</SelectValue>168 <SelectValue>
169 {(value: SourceControlWritingStyleMode | null) =>
170 value === null ? "Mixed" : MODE_OPTIONS[value].label
171 }
172 </SelectValue>
131 </SelectTrigger>173 </SelectTrigger>
132 <SelectPopup align="end" alignItemWithTrigger={false}>174 <SelectPopup align="end" alignItemWithTrigger={false}>
133 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (175 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
...@@ -139,7 +181,49 @@ export function SourceControlWritingSettingsSection() {...@@ -139,7 +181,49 @@ export function SourceControlWritingSettingsSection() {
139 </Select>181 </Select>
140 }182 }
141 >183 >
142 {style.mode === "custom" ? (184 {writingStyleMixed ? (
185 <div className="mt-3 max-w-2xl space-y-2 pb-3.5">
186 {editingAllInstructions ? (
187 <>
188 <Textarea
189 value={allInstructions ?? ""}
190 onChange={(event) => setAllInstructions(event.target.value)}
191 rows={4}
192 aria-label="Custom source control instructions for all selected environments"
193 placeholder="Write the instructions each selected environment should use."
194 />
195 <Button
196 size="sm"
197 variant="outline"
198 disabled={allInstructions === null}
199 onClick={() => {
200 if (allInstructions === null) return;
201 updateSettings({
202 sourceControlWritingStyle: {
203 mode: "custom",
204 customInstructions: allInstructions.trim(),
205 },
206 });
207 setEditingAllInstructions(false);
208 }}
209 >
210 Apply instructions to all
211 </Button>
212 </>
213 ) : (
214 <Button
215 size="sm"
216 variant="outline"
217 onClick={() => {
218 setAllInstructions(null);
219 setEditingAllInstructions(true);
220 }}
221 >
222 Write custom instructions for all
223 </Button>
224 )}
225 </div>
226 ) : style.mode === "custom" ? (
143 <div className="mt-3 max-w-2xl pb-3.5">227 <div className="mt-3 max-w-2xl pb-3.5">
144 <Textarea228 <Textarea
145 key={style.customInstructions}229 key={style.customInstructions}
...@@ -161,9 +245,12 @@ export function SourceControlWritingSettingsSection() {...@@ -161,9 +245,12 @@ export function SourceControlWritingSettingsSection() {
161245
162 <SettingsRow246 <SettingsRow
163 serverScoped247 serverScoped
248 settingKeys={["sourceControlWritingStyle"]}
249 mixed={templatesMixed}
164 {...searchableSetting("follow-change-request-templates")}250 {...searchableSetting("follow-change-request-templates")}
165 description="Use the repository's template for change request descriptions when available."251 description="Use the repository's template for change request descriptions when available."
166 resetAction={252 resetAction={
253 templatesMixed ||
167 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (254 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
168 <SettingResetButton255 <SettingResetButton
169 label="change request templates"256 label="change request templates"
...@@ -179,7 +266,8 @@ export function SourceControlWritingSettingsSection() {...@@ -179,7 +266,8 @@ export function SourceControlWritingSettingsSection() {
179 }266 }
180 control={267 control={
181 <Switch268 <Switch
182 checked={style.followChangeRequestTemplates}269 mixed={templatesMixed}
270 checked={templatesMixed ? false : style.followChangeRequestTemplates}
183 onCheckedChange={(checked) =>271 onCheckedChange={(checked) =>
184 updateSettings({272 updateSettings({
185 sourceControlWritingStyle: {273 sourceControlWritingStyle: {
...@@ -194,59 +282,77 @@ export function SourceControlWritingSettingsSection() {...@@ -194,59 +282,77 @@ export function SourceControlWritingSettingsSection() {
194282
195 <SettingsRow283 <SettingsRow
196 serverScoped284 serverScoped
285 settingKeys={["sourceControlWriterModelSelection"]}
197 {...searchableSetting("source-control-writer-model")}286 {...searchableSetting("source-control-writer-model")}
198 description="Model for source control text and branch or bookmark names. Off uses the global default."287 description="Model for source control text and branch or bookmark names. Off uses the environment's text generation model."
199 control={288 control={
200 <div className="flex flex-wrap items-center justify-end gap-2">289 !hasServerTargets ? (
201 {usesDedicatedModel && !canEnableDedicatedModel ? (290 <span className="text-sm text-muted-foreground">
202 <span className="text-sm text-muted-foreground">291 Connect an environment to choose its source control writer model.
203 No text generation providers available.292 </span>
204 </span>293 ) : (
205 ) : null}294 <div className="flex flex-wrap items-center justify-end gap-2">
206 {usesDedicatedModel && canEnableDedicatedModel ? (295 {usesDedicatedModel && !canEnableDedicatedModel ? (
207 <ProviderModelPicker296 <span className="text-sm text-muted-foreground">
208 activeInstanceId={activeSelection.instanceId}297 No text generation providers available.
209 model={activeSelection.model}298 </span>
210 lockedProvider={null}299 ) : null}
211 instanceEntries={instanceEntries}300 {usesDedicatedModel && canEnableDedicatedModel ? (
212 modelOptionsByInstance={modelOptionsByInstance}301 <ProviderModelPicker
213 triggerVariant="outline"302 activeInstanceId={activeSelection.instanceId}
214 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}303 model={activeSelection.model}
215 triggerAriaLabel="Source control writer model"304 lockedProvider={null}
216 {...(environmentId305 instanceEntries={instanceEntries}
217 ? {306 modelOptionsByInstance={modelOptionsByInstance}
218 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {307 triggerVariant="outline"
219 void navigate({308 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
220 to: "/settings/providers",309 triggerAriaLabel="Source control writer model"
221 search: { environmentId, instanceId },310 {...(mixedWriterModel ? { triggerLabel: "Mixed" } : {})}
222 });311 {...(environmentId
223 },312 ? {
313 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
314 void navigate({
315 to: "/settings/providers",
316 search: { environmentId, instanceId },
317 });
318 },
319 }
320 : {})}
321 getModelDisabledReason={writerModelDisabledReason}
322 onInstanceModelChange={(instanceId, model) => {
323 const reason = writerModelDisabledReason(instanceId, model);
324 if (reason) {
325 toastManager.add({
326 type: "error",
327 title: "Source control writer model not saved",
328 description: reason,
329 });
330 return;
224 }331 }
225 : {})}332 updateSettings({
226 onInstanceModelChange={(instanceId, model) => {333 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
334 });
335 }}
336 />
337 ) : null}
338 <Switch
339 checked={usesDedicatedModel}
340 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
341 onCheckedChange={(checked) =>
227 updateSettings({342 updateSettings({
228 sourceControlWriterModelSelection: createModelSelection(instanceId, model),343 sourceControlWriterModelSelection: checked
229 });344 ? createModelSelection(
230 }}345 defaultModelSelection.instanceId,
346 defaultModelSelection.model,
347 defaultModelSelection.options,
348 )
349 : null,
350 })
351 }
352 aria-label="Use a separate source control writer model"
231 />353 />
232 ) : null}354 </div>
233 <Switch355 )
234 checked={usesDedicatedModel}
235 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
236 onCheckedChange={(checked) =>
237 updateSettings({
238 sourceControlWriterModelSelection: checked
239 ? createModelSelection(
240 defaultModelSelection.instanceId,
241 defaultModelSelection.model,
242 defaultModelSelection.options,
243 )
244 : null,
245 })
246 }
247 aria-label="Use a separate source control writer model"
248 />
249 </div>
250 }356 }
251 />357 />
252 </SettingsSection>358 </SettingsSection>
apps/web/src/components/settings/scopedSettings.test.ts created+450
...@@ -0,0 +1,450 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 ProjectId,
5 type ServerSettings,
6} from "@t3tools/contracts";
7import { describe, expect, it, vi } from "vite-plus/test";
8
9import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
10import {
11 listProjectOverrides,
12 persistScopedSettingsPatch,
13 planProjectOverridesClear,
14 planScopedSettingsClear,
15 planScopedSettingsPatch,
16 resolveScopedSettingsTargets,
17 scopedSettingsAreMixed,
18 scopedSettingsSource,
19 selectScopedSettingsEnvironments,
20} from "./scopedSettings";
21import { resolveSettingsScope } from "./settingsScope";
22
23function environment(
24 id: string,
25 options: {
26 connected?: boolean;
27 loaded?: boolean;
28 settings?: Partial<ServerSettings>;
29 projectOverrides?: boolean;
30 } = {},
31) {
32 return {
33 environmentId: EnvironmentId.make(id),
34 label: id,
35 connection: {
36 phase: options.connected === false ? ("offline" as const) : ("connected" as const),
37 },
38 serverConfig:
39 options.loaded === false
40 ? null
41 : {
42 settings: { ...DEFAULT_SERVER_SETTINGS, ...options.settings },
43 environment: {
44 capabilities: { projectSettingsOverrides: options.projectOverrides !== false },
45 },
46 },
47 };
48}
49
50const laptop = environment("Laptop");
51const server = environment("Server");
52const offline = environment("Offline", { connected: false });
53const loading = environment("Loading", { loaded: false });
54const environments = [laptop, server, offline, loading];
55const all = resolveSettingsScope({}, [], environments);
56const named = resolveSettingsScope({ machine: server.environmentId }, [], environments);
57
58const projectId = ProjectId.make("project");
59const laptopProjectId = ProjectId.make("laptop-project");
60const member = {
61 id: projectId,
62 environmentId: server.environmentId,
63 title: "Project",
64 workspaceRoot: "/repo",
65 physicalProjectKey: `${server.environmentId}:/repo`,
66 environmentLabel: server.label,
67 defaultModelSelection: null,
68 scripts: [],
69 createdAt: "2026-09-07T00:00:00.000Z",
70 updatedAt: "2026-09-07T00:00:00.000Z",
71};
72const laptopMember = {
73 ...member,
74 id: laptopProjectId,
75 environmentId: laptop.environmentId,
76 physicalProjectKey: `${laptop.environmentId}:/repo`,
77 environmentLabel: laptop.label,
78};
79const group: SidebarProjectSnapshot = {
80 ...member,
81 projectKey: "project-group",
82 displayName: "Project",
83 memberProjects: [member, laptopMember],
84 memberProjectRefs: [
85 { environmentId: server.environmentId, projectId },
86 { environmentId: laptop.environmentId, projectId: laptopProjectId },
87 ],
88 groupedProjectCount: 2,
89 environmentPresence: "remote-only",
90 allRemoteMembersAreDesktopLocal: false,
91 allRemoteMembersAreWsl: false,
92 remoteEnvironmentLabels: [server.label, laptop.label],
93};
94const project = resolveSettingsScope({ project: group.projectKey }, [group], environments);
95const checkout = resolveSettingsScope(
96 { project: group.projectKey, machine: server.environmentId, checkout: member.physicalProjectKey },
97 [group],
98 environments,
99);
100
101describe("scoped settings targets", () => {
102 it("uses the named environment even when a different primary is available", () => {
103 const selected = selectScopedSettingsEnvironments(named, environments, laptop.environmentId);
104 expect(selected.environments).toEqual([server]);
105 expect(selected.environment).toBe(server);
106 });
107
108 it("keeps an offline named environment selected without falling back to primary", () => {
109 const scope = resolveSettingsScope({ machine: offline.environmentId }, [], environments);
110 const selected = selectScopedSettingsEnvironments(scope, environments, laptop.environmentId);
111 expect(selected.environments).toEqual([offline]);
112 expect(selected.connectedEnvironments).toEqual([]);
113 expect(selected.environment).toBeNull();
114 expect(
115 planScopedSettingsPatch(scope, environments, { enableProviderUpdateChecks: false }),
116 ).toMatchObject({
117 serverWrites: [],
118 unavailableReason: "Connect Offline to save this setting.",
119 });
120 });
121
122 it("prefers the selected primary as the aggregate representative without including disconnected targets", () => {
123 const selected = selectScopedSettingsEnvironments(all, environments, server.environmentId);
124 expect(selected.environment).toBe(server);
125 expect(selected.environments).toEqual(environments);
126 expect(selected.connectedEnvironments).toEqual([laptop, server]);
127 });
128
129 it("resolves each member's effective settings and source at project scope", () => {
130 const overridden = environment("Server", {
131 settings: {
132 defaultAutoPull: false,
133 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
134 },
135 });
136 const targets = resolveScopedSettingsTargets(project, [laptop, overridden]);
137 expect(targets.map((target) => [target.projectId, target.settings.defaultAutoPull])).toEqual([
138 [projectId, true],
139 [laptopProjectId, false],
140 ]);
141 expect(scopedSettingsSource(targets, ["defaultAutoPull"])).toBe("mixed");
142 expect(scopedSettingsSource([targets[0]!], ["defaultAutoPull"])).toBe("project");
143 expect(scopedSettingsSource(targets, ["enableProviderUpdateChecks"])).toBe("environment");
144 expect(scopedSettingsAreMixed(targets, ["defaultAutoPull"])).toBe(true);
145 });
146});
147
148describe("scoped settings writes", () => {
149 it("isolates a formerly shared server preference to the named environment", async () => {
150 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
151 const persistClient = vi.fn();
152 await persistScopedSettingsPatch(
153 planScopedSettingsPatch(named, environments, { sidebarAutoSettleOnMerge: false }),
154 persistServer,
155 persistClient,
156 );
157 expect(persistServer.mock.calls).toEqual([
158 [
159 {
160 environmentId: server.environmentId,
161 input: { patch: { sidebarAutoSettleOnMerge: false } },
162 },
163 ],
164 ]);
165 expect(persistClient).not.toHaveBeenCalled();
166 });
167
168 it("writes an aggregate preference only to connected environments with loaded configuration", async () => {
169 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
170 const persistClient = vi.fn();
171 await persistScopedSettingsPatch(
172 planScopedSettingsPatch(all, environments, { enableProviderUpdateChecks: false }),
173 persistServer,
174 persistClient,
175 );
176 expect(persistServer.mock.calls.map(([input]) => input.environmentId)).toEqual([
177 laptop.environmentId,
178 server.environmentId,
179 ]);
180 expect(persistClient).not.toHaveBeenCalled();
181 });
182
183 it("persists client keys locally at any scope alongside server keys", async () => {
184 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
185 const persistClient = vi.fn();
186 await persistScopedSettingsPatch(
187 planScopedSettingsPatch(named, environments, {
188 diffIgnoreWhitespace: false,
189 enableProviderUpdateChecks: false,
190 }),
191 persistServer,
192 persistClient,
193 );
194 expect(persistClient).toHaveBeenCalledExactlyOnceWith({ diffIgnoreWhitespace: false });
195 expect(persistServer).toHaveBeenCalledExactlyOnceWith({
196 environmentId: server.environmentId,
197 input: { patch: { enableProviderUpdateChecks: false } },
198 });
199 });
200
201 it("writes project overrides into each member's entry on its environment", () => {
202 const withExisting = environment("Server", {
203 settings: {
204 projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
205 },
206 });
207 const plan = planScopedSettingsPatch(project, [laptop, withExisting], {
208 defaultAutoPull: true,
209 });
210 expect(plan.unavailableReason).toBeNull();
211 expect(plan.serverWrites).toEqual([
212 {
213 environmentId: server.environmentId,
214 label: server.label,
215 patch: {
216 projectSettingsOverrides: {
217 [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true },
218 },
219 },
220 },
221 {
222 environmentId: laptop.environmentId,
223 label: laptop.label,
224 patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } },
225 },
226 ]);
227 expect(
228 planScopedSettingsPatch(checkout, [laptop, server], { defaultAutoPull: true }),
229 ).toMatchObject({
230 serverWrites: [{ environmentId: server.environmentId }],
231 });
232 });
233
234 it("refuses environment-wide keys and older servers at project scope", () => {
235 expect(
236 planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),
237 ).toMatchObject({
238 serverWrites: [],
239 unavailableReason: "This setting is environment-wide and cannot be overridden by a project.",
240 });
241 const legacy = environment("Server", { projectOverrides: false });
242 expect(
243 planScopedSettingsPatch(checkout, [laptop, legacy], { defaultAutoPull: true }),
244 ).toMatchObject({ serverWrites: [], unavailableReason: expect.stringContaining("update") });
245 });
246
247 it("clears overrides per member and removes an emptied entry", () => {
248 const withOverrides = environment("Server", {
249 settings: {
250 projectSettingsOverrides: {
251 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
252 },
253 },
254 });
255 const plan = planScopedSettingsClear(checkout, [laptop, withOverrides], ["defaultAutoPull"]);
256 expect(plan.serverWrites).toEqual([
257 {
258 environmentId: server.environmentId,
259 label: server.label,
260 patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
261 },
262 ]);
263 expect(
264 planScopedSettingsClear(
265 checkout,
266 [laptop, withOverrides],
267 ["defaultAutoPull", "enableAgentBrowserAccess"],
268 ).serverWrites[0]?.patch,
269 ).toEqual({ projectSettingsOverrides: { [projectId]: null } });
270 });
271
272 it("never substitutes an environment-default write for an invalid scope", () => {
273 const scope = resolveSettingsScope({ machine: "removed" }, [group], environments);
274 const plan = planScopedSettingsPatch(scope, environments, { enableAgentBrowserAccess: false });
275 expect(plan.serverWrites).toEqual([]);
276 expect(plan.hasClientWrite).toBe(false);
277 expect(plan.unavailableReason).not.toBeNull();
278 });
279
280 it("waits for every target and identifies both RPC failures and rejected writes", async () => {
281 const third = environment("Third");
282 const fourth = environment("Fourth");
283 const selected = [...environments, third, fourth];
284 const scope = resolveSettingsScope({}, [], selected);
285 const persistServer = vi
286 .fn()
287 .mockResolvedValueOnce({ _tag: "Success" })
288 .mockResolvedValueOnce({ _tag: "Failure" })
289 .mockRejectedValueOnce(new Error("Disconnected during save"))
290 .mockResolvedValueOnce({ _tag: "Success" });
291 const result = await persistScopedSettingsPatch(
292 planScopedSettingsPatch(scope, selected, { enableAgentBrowserAccess: false }),
293 persistServer,
294 vi.fn(),
295 );
296 expect(result.savedEnvironmentCount).toBe(2);
297 expect(result.failedEnvironments.map(({ label }) => label)).toEqual([
298 server.label,
299 third.label,
300 ]);
301 expect(persistServer).toHaveBeenCalledTimes(4);
302 });
303});
304
305describe("scoped settings mixed values", () => {
306 it("compares only requested settings across connected targets", () => {
307 const changed = environment("Changed", { settings: { enableAgentBrowserAccess: false } });
308 const targets = resolveScopedSettingsTargets(all, [laptop, changed]);
309 expect(scopedSettingsAreMixed(targets, ["enableAgentBrowserAccess"])).toBe(true);
310 expect(scopedSettingsAreMixed(targets, ["enableProviderUpdateChecks"])).toBe(false);
311 expect(scopedSettingsAreMixed([], ["enableAgentBrowserAccess"])).toBe(false);
312 });
313
314 it("treats independently decoded equal nested settings as the same value", () => {
315 const style = {
316 ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
317 mode: "custom" as const,
318 customInstructions: "Use plain language",
319 };
320 const first = environment("First", { settings: { sourceControlWritingStyle: { ...style } } });
321 const second = environment("Second", { settings: { sourceControlWritingStyle: { ...style } } });
322 const targets = resolveScopedSettingsTargets(all, [first, second]);
323 expect(scopedSettingsAreMixed(targets, ["sourceControlWritingStyle"])).toBe(false);
324 });
325});
326
327describe("project overrides at environment scope", () => {
328 const laptop = EnvironmentId.make("laptop");
329 const desk = EnvironmentId.make("desk");
330 const fleet = ProjectId.make("fleet");
331 const t3 = ProjectId.make("t3");
332 const environment = (
333 environmentId: EnvironmentId,
334 overrides: ServerSettings["projectSettingsOverrides"],
335 ) => ({
336 environmentId,
337 label: environmentId,
338 connection: { phase: "connected" as const },
339 serverConfig: {
340 settings: { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: overrides },
341 environment: { capabilities: { projectSettingsOverrides: true } },
342 },
343 });
344
345 it("lists only the projects that override the keys", () => {
346 const entries = listProjectOverrides(
347 [
348 environment(laptop, {
349 [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" },
350 [t3]: { defaultThreadEnvMode: "local" },
351 }),
352 environment(desk, { [fleet]: { defaultAutoPull: false } }),
353 ],
354 ["defaultAutoPull"],
355 );
356 expect(entries).toEqual([
357 { environmentId: laptop, projectId: fleet },
358 { environmentId: desk, projectId: fleet },
359 ]);
360 });
361
362 it("clears only those keys and drops entries that become empty", () => {
363 const plan = planProjectOverridesClear(
364 [
365 environment(laptop, {
366 [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" },
367 [t3]: { defaultAutoPull: true },
368 }),
369 ],
370 [
371 { environmentId: laptop, projectId: fleet },
372 { environmentId: laptop, projectId: t3 },
373 ],
374 ["defaultAutoPull"],
375 );
376 expect(plan.serverWrites).toEqual([
377 {
378 environmentId: laptop,
379 label: laptop,
380 patch: {
381 projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: null },
382 },
383 },
384 ]);
385 });
386});
387
388describe("partial object patches at project scope", () => {
389 it("completes a writing style field patch from the target's effective value", () => {
390 const environmentId = EnvironmentId.make("laptop");
391 const projectId = ProjectId.make("fleet");
392 const member = {
393 id: projectId,
394 environmentId,
395 physicalProjectKey: "laptop:/repo",
396 environmentLabel: "Laptop",
397 title: "fleet",
398 workspaceRoot: "/repo",
399 defaultModelSelection: null,
400 scripts: [],
401 createdAt: "2026-01-01T00:00:00.000Z",
402 updatedAt: "2026-01-01T00:00:00.000Z",
403 };
404 const settings = {
405 ...DEFAULT_SERVER_SETTINGS,
406 projectSettingsOverrides: {
407 [projectId]: {
408 sourceControlWritingStyle: {
409 mode: "custom" as const,
410 customInstructions: "Keep it short.",
411 followChangeRequestTemplates: false,
412 },
413 },
414 },
415 };
416 const plan = planScopedSettingsPatch(
417 {
418 kind: "project",
419 group: {} as never,
420 environmentId: null,
421 label: "fleet",
422 members: [member as never],
423 environmentIds: [environmentId],
424 },
425 [
426 {
427 environmentId,
428 label: "Laptop",
429 connection: { phase: "connected" },
430 serverConfig: {
431 settings,
432 environment: { capabilities: { projectSettingsOverrides: true } },
433 },
434 },
435 ],
436 { sourceControlWritingStyle: { customInstructions: "Be terse." } },
437 );
438 expect(plan.serverWrites[0]?.patch).toEqual({
439 projectSettingsOverrides: {
440 [projectId]: {
441 sourceControlWritingStyle: {
442 mode: "custom",
443 customInstructions: "Be terse.",
444 followChangeRequestTemplates: false,
445 },
446 },
447 },
448 });
449 });
450});
apps/web/src/components/settings/scopedSettings.ts created+361
...@@ -0,0 +1,361 @@
1import {
2 ClientSettingsSchema,
3 type ClientSettingsPatch,
4 type EnvironmentId,
5 PROJECT_SCOPED_SERVER_SETTING_KEYS,
6 type ProjectId,
7 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,
9 ServerSettings,
10 type ServerSettingsPatch,
11} from "@t3tools/contracts";
12import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
13import {
14 clearProjectSettingsOverrides,
15 resolveProjectSettings,
16 type ProjectSettingSource,
17} from "@t3tools/shared/projectSettings";
18import * as Equal from "effect/Equal";
19
20import type { ResolvedSettingsScope } from "./settingsScope";
21
22export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
23
24interface ScopedSettingsEnvironment {
25 readonly environmentId: EnvironmentId;
26 readonly label: string;
27 readonly connection: { readonly phase: EnvironmentConnectionPhase };
28 readonly serverConfig: {
29 readonly settings: ServerSettings;
30 readonly environment?: {
31 readonly capabilities: { readonly projectSettingsOverrides?: boolean | undefined };
32 };
33 } | null;
34}
35
36const SERVER_KEYS = new Set<string>(Object.keys(ServerSettings.fields));
37const CLIENT_KEYS = new Set<string>(Object.keys(ClientSettingsSchema.fields));
38const PROJECT_SCOPED_KEYS = new Set<string>(PROJECT_SCOPED_SERVER_SETTING_KEYS);
39
40export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey {
41 return PROJECT_SCOPED_KEYS.has(key);
42}
43
44function isPlainObject(value: unknown): value is Record<string, unknown> {
45 return typeof value === "object" && value !== null && !Array.isArray(value);
46}
47
48/** The representative supplies display values, never the set of write targets. */
49export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>(
50 scope: ResolvedSettingsScope,
51 available: readonly T[],
52 primaryEnvironmentId: EnvironmentId | null,
53) {
54 const selectedIds = new Set(scope.environmentIds);
55 const environments = available.filter((environment) =>
56 selectedIds.has(environment.environmentId),
57 );
58 const connectedEnvironments = environments.filter(
59 (environment) =>
60 environment.connection.phase === "connected" && environment.serverConfig !== null,
61 );
62 const environment =
63 connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ??
64 connectedEnvironments[0] ??
65 null;
66 return { environments, connectedEnvironments, environment };
67}
68
69/**
70 * One (environment, project) pair the scope writes to, with that project's
71 * effective settings. Environment scopes have no member and read the
72 * environment settings directly.
73 */
74export interface ScopedSettingsTarget {
75 readonly environmentId: EnvironmentId;
76 /** The environment's label; a project is the same project on every environment. */
77 readonly label: string;
78 readonly projectId: ProjectId | null;
79 readonly settings: ServerSettings;
80 readonly sources: Readonly<Record<ProjectScopedServerSettingKey, ProjectSettingSource>>;
81}
82
83/** Effective settings per connected target: members at project scope, environments otherwise. */
84export function resolveScopedSettingsTargets(
85 scope: ResolvedSettingsScope,
86 connectedEnvironments: readonly ScopedSettingsEnvironment[],
87): readonly ScopedSettingsTarget[] {
88 const byId = new Map(
89 connectedEnvironments.map((environment) => [environment.environmentId, environment]),
90 );
91 if (scope.kind === "project" || scope.kind === "checkout") {
92 return scope.members.flatMap((member) => {
93 const environment = byId.get(member.environmentId);
94 if (!environment?.serverConfig) return [];
95 const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id);
96 return [
97 {
98 environmentId: member.environmentId,
99 label: environment.label,
100 projectId: member.id,
101 settings: resolved.settings,
102 sources: resolved.sources,
103 },
104 ];
105 });
106 }
107 return connectedEnvironments.flatMap((environment) =>
108 environment.serverConfig
109 ? [
110 {
111 environmentId: environment.environmentId,
112 label: environment.label,
113 projectId: null,
114 settings: environment.serverConfig.settings,
115 sources: resolveProjectSettings(environment.serverConfig.settings, null).sources,
116 },
117 ]
118 : [],
119 );
120}
121
122export function scopedSettingsAreMixed(
123 targets: readonly Pick<ScopedSettingsTarget, "settings">[],
124 keys: readonly (keyof ServerSettings)[],
125): boolean {
126 const first = targets[0];
127 return (
128 first !== undefined &&
129 targets.some((candidate) =>
130 keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])),
131 )
132 );
133}
134
135export type ScopedSettingSource = ProjectSettingSource | "mixed";
136
137/** Whether the keys are overridden on every target, inherited on every target, or split. */
138export function scopedSettingsSource(
139 targets: readonly Pick<ScopedSettingsTarget, "sources">[],
140 keys: readonly (keyof ServerSettings)[],
141): ScopedSettingSource {
142 const scoped = keys.filter(isProjectScopedSettingKey);
143 if (scoped.length === 0 || targets.length === 0) return "environment";
144 const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));
145 return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment";
146}
147
148interface ScopedServerWrite {
149 readonly environmentId: EnvironmentId;
150 readonly label: string;
151 readonly patch: ServerSettingsPatch;
152}
153
154function projectOverrideWrites(
155 scope: Extract<ResolvedSettingsScope, { kind: "project" | "checkout" }>,
156 environments: readonly ScopedSettingsEnvironment[],
157 update: (
158 current: ProjectSettingsOverrides,
159 settings: ServerSettings,
160 projectId: ProjectId,
161 ) => ProjectSettingsOverrides | null,
162): ScopedServerWrite[] {
163 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
164 const writes = new Map<EnvironmentId, ScopedServerWrite>();
165 for (const member of scope.members) {
166 const environment = byId.get(member.environmentId);
167 if (
168 !environment?.serverConfig ||
169 environment.connection.phase !== "connected" ||
170 environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
171 ) {
172 continue;
173 }
174 const settings = environment.serverConfig.settings;
175 const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id);
176 const existing = writes.get(member.environmentId);
177 writes.set(member.environmentId, {
178 environmentId: member.environmentId,
179 label: environment.label,
180 patch: {
181 projectSettingsOverrides: {
182 ...existing?.patch.projectSettingsOverrides,
183 [member.id]: entry,
184 },
185 },
186 });
187 }
188 return [...writes.values()];
189}
190
191/**
192 * Environment scopes write the patch to every connected environment; project
193 * and checkout scopes write the scopable keys into each member's override
194 * entry on its environment. Client keys always persist locally.
195 */
196export function planScopedSettingsPatch(
197 scope: ResolvedSettingsScope,
198 environments: readonly ScopedSettingsEnvironment[],
199 patch: ScopedSettingsPatch,
200) {
201 const clientPatch = Object.fromEntries(
202 Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)),
203 ) as ClientSettingsPatch;
204 const serverPatch = Object.fromEntries(
205 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
206 ) as ServerSettingsPatch;
207 const serverKeys = Object.keys(serverPatch);
208 const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null);
209 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
210 const unscopableKeys = isProjectScope
211 ? serverKeys.filter((key) => !isProjectScopedSettingKey(key))
212 : [];
213 const serverWrites: ScopedServerWrite[] =
214 serverKeys.length === 0
215 ? []
216 : isProjectScope
217 ? unscopableKeys.length > 0
218 ? []
219 : projectOverrideWrites(scope, environments, (current, settings, projectId) => {
220 // Object-valued keys arrive as partial patches (the writing style
221 // rows send one field); an override entry stores the whole value,
222 // so complete the patch from the target's effective value.
223 const effective = resolveProjectSettings(settings, projectId).settings;
224 const next: Record<string, unknown> = { ...current };
225 for (const [key, value] of Object.entries(serverPatch)) {
226 const base = effective[key as keyof ServerSettings];
227 next[key] =
228 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
229 }
230 return next as ProjectSettingsOverrides;
231 })
232 : scope.kind === "all" || scope.kind === "environment"
233 ? connectedEnvironments.map((environment) => ({
234 environmentId: environment.environmentId,
235 label: environment.label,
236 patch: serverPatch,
237 }))
238 : [];
239 const hasClientWrite = Object.keys(clientPatch).length > 0;
240 const hasWrite = hasClientWrite || serverWrites.length > 0;
241 const unavailableReason =
242 hasWrite || Object.keys(patch).length === 0
243 ? null
244 : scope.kind === "unavailable"
245 ? scope.message
246 : unscopableKeys.length > 0
247 ? "This setting is environment-wide and cannot be overridden by a project."
248 : isProjectScope
249 ? "Connect the selected checkouts, or update their environments, to save a project override."
250 : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`;
251 return { clientPatch, hasClientWrite, serverWrites, unavailableReason };
252}
253
254/** Remove the keys' project overrides so each member inherits its environment value again. */
255export function planScopedSettingsClear(
256 scope: ResolvedSettingsScope,
257 environments: readonly ScopedSettingsEnvironment[],
258 keys: readonly ProjectScopedServerSettingKey[],
259) {
260 const serverWrites =
261 scope.kind === "project" || scope.kind === "checkout"
262 ? projectOverrideWrites(scope, environments, (_current, settings, projectId) =>
263 clearProjectSettingsOverrides(settings, projectId, keys),
264 )
265 : [];
266 return {
267 clientPatch: {} as ClientSettingsPatch,
268 hasClientWrite: false,
269 serverWrites,
270 unavailableReason:
271 serverWrites.length > 0
272 ? null
273 : "Connect the selected checkouts, or update their environments, to reset this override.",
274 };
275}
276
277export interface ProjectOverrideEntry {
278 readonly environmentId: EnvironmentId;
279 readonly projectId: ProjectId;
280}
281
282/**
283 * The projects on the selected environments that override `keys`. An
284 * environment edit leaves these untouched, so the row can name them and
285 * offer to clear them.
286 */
287export function listProjectOverrides(
288 environments: readonly ScopedSettingsEnvironment[],
289 keys: readonly (keyof ServerSettings)[],
290): readonly ProjectOverrideEntry[] {
291 const scoped = keys.filter(isProjectScopedSettingKey);
292 if (scoped.length === 0) return [];
293 return environments.flatMap((environment) => {
294 const overrides = environment.serverConfig?.settings.projectSettingsOverrides;
295 if (!overrides) return [];
296 return Object.entries(overrides).flatMap(([projectId, entry]) =>
297 scoped.some((key) => Object.hasOwn(entry, key))
298 ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }]
299 : [],
300 );
301 });
302}
303
304/** Drop `keys` from the named project entries so they follow the environment again. */
305export function planProjectOverridesClear(
306 environments: readonly ScopedSettingsEnvironment[],
307 entries: readonly ProjectOverrideEntry[],
308 keys: readonly ProjectScopedServerSettingKey[],
309) {
310 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
311 const writes = new Map<EnvironmentId, ScopedServerWrite>();
312 for (const { environmentId, projectId } of entries) {
313 const environment = byId.get(environmentId);
314 if (!environment?.serverConfig || environment.connection.phase !== "connected") continue;
315 const settings = environment.serverConfig.settings;
316 const existing = writes.get(environmentId);
317 writes.set(environmentId, {
318 environmentId,
319 label: environment.label,
320 patch: {
321 projectSettingsOverrides: {
322 ...existing?.patch.projectSettingsOverrides,
323 [projectId]: clearProjectSettingsOverrides(settings, projectId, keys),
324 },
325 },
326 });
327 }
328 const serverWrites = [...writes.values()];
329 return {
330 clientPatch: {} as ClientSettingsPatch,
331 hasClientWrite: false,
332 serverWrites,
333 unavailableReason:
334 serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.",
335 };
336}
337
338/** Wait for every target so a failed environment does not hide successful or later writes. */
339export async function persistScopedSettingsPatch(
340 plan: ReturnType<typeof planScopedSettingsPatch>,
341 persistServer: (input: {
342 environmentId: EnvironmentId;
343 input: { patch: ServerSettingsPatch };
344 }) => Promise<{ readonly _tag: "Success" | "Failure" }>,
345 persistClient: (patch: ClientSettingsPatch) => void,
346) {
347 if (plan.hasClientWrite) persistClient(plan.clientPatch);
348 const results = await Promise.allSettled(
349 plan.serverWrites.map(({ environmentId, patch }) =>
350 persistServer({ environmentId, input: { patch } }),
351 ),
352 );
353 const failedEnvironments = plan.serverWrites.filter((_, index) => {
354 const result = results[index];
355 return result?.status !== "fulfilled" || result.value._tag === "Failure";
356 });
357 return {
358 failedEnvironments,
359 savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length,
360 };
361}
apps/web/src/components/settings/settingsLayout.tsx+166-24
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { InfoIcon, Undo2Icon } from "lucide-react";1import { InfoIcon, Undo2Icon } from "lucide-react";
2import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
3import * as Equal from "effect/Equal";
2import { useLocation, useNavigate } from "@tanstack/react-router";4import { useLocation, useNavigate } from "@tanstack/react-router";
3import {5import {
4 createContext,6 createContext,
...@@ -19,6 +21,21 @@ import { cn } from "../../lib/utils";...@@ -19,6 +21,21 @@ import { cn } from "../../lib/utils";
19import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";21import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
20import { Button } from "../ui/button";22import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";23import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
24import { useOptionalSettingsScope } from "./SettingsScopeContext";
25import {
26 isProjectScopedSettingKey,
27 listProjectOverrides,
28 scopedSettingsAreMixed,
29 scopedSettingsSource,
30} from "./scopedSettings";
31import { useClearProjectOverrides, useClearScopedSettings } from "./useScopedSettings";
32import {
33 SettingInheritance,
34 type SettingInheritanceState,
35 type SettingOverridingProject,
36} from "./SettingInheritance";
37
38const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = [];
2239
23declare module "@tanstack/react-router" {40declare module "@tanstack/react-router" {
24 interface HistoryState {41 interface HistoryState {
...@@ -253,8 +270,11 @@ export function SettingsRow({...@@ -253,8 +270,11 @@ export function SettingsRow({
253 description,270 description,
254 status,271 status,
255 resetAction,272 resetAction,
273 onResetOverride,
256 control,274 control,
257 serverScoped = false,275 serverScoped = false,
276 settingKeys = EMPTY_SETTING_KEYS,
277 mixed: mixedOverride,
258 children,278 children,
259 className,279 className,
260 ...rowProps280 ...rowProps
...@@ -263,37 +283,152 @@ export function SettingsRow({...@@ -263,37 +283,152 @@ export function SettingsRow({
263 description?: ReactNode;283 description?: ReactNode;
264 status?: ReactNode;284 status?: ReactNode;
265 resetAction?: ReactNode;285 resetAction?: ReactNode;
286 /** Replaces the default override clear for rows with side effects beyond the settings key. */
287 onResetOverride?: () => void;
266 control?: ReactNode;288 control?: ReactNode;
267 serverScoped?: boolean;289 serverScoped?: boolean;
290 settingKeys?: readonly (keyof ServerSettings)[];
291 mixed?: boolean;
268 children?: ReactNode;292 children?: ReactNode;
269}) {293}) {
270 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);294 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
271 const primarySettingsAvailable = usePrimarySettingsAvailable();295 const primarySettingsAvailable = usePrimarySettingsAvailable();
272 const unavailable = serverScoped && !primarySettingsAvailable;296 const context = useOptionalSettingsScope();
273 const renderedReset = unavailable ? null : resetAction;297 const clearOverrides = useClearScopedSettings();
298 const clearProjectOverrides = useClearProjectOverrides();
299 const isProjectScope =
300 context !== null && (context.scope.kind === "project" || context.scope.kind === "checkout");
301 const scopedKeys = settingKeys.filter(isProjectScopedSettingKey);
302 // A project scope can only edit keys that support overrides; the rest stay
303 // visible so the user sees the inherited value, but cannot change it here.
304 const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0;
305 const mixed =
306 mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys));
307 const source =
308 context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;
309 const unavailable =
310 serverScoped &&
311 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable);
312 const inheritedFrom =
313 source === "environment" && context?.scope.environmentIds.length === 1
314 ? (context.environments.find(
315 (environment) => environment.environmentId === context.scope.environmentIds[0],
316 )?.label ?? "environment")
317 : "environment";
318 const environmentSettingsById = useMemo(
319 () =>
320 new Map(
321 (context?.connectedEnvironments ?? []).flatMap((environment) =>
322 environment.serverConfig
323 ? [[environment.environmentId, environment.serverConfig.settings] as const]
324 : [],
325 ),
326 ),
327 [context?.connectedEnvironments],
328 );
329 // At environment scope, projects with their own value keep it when the
330 // environment default changes; the chain names them and can reset them.
331 const overridingProjects = useMemo((): SettingOverridingProject[] => {
332 if (context === null || isProjectScope || scopedKeys.length === 0) return [];
333 return listProjectOverrides(context.connectedEnvironments, scopedKeys).flatMap((entry) => {
334 const group = context.groups.find((candidate) =>
335 candidate.memberProjects.some(
336 (member) => member.environmentId === entry.environmentId && member.id === entry.projectId,
337 ),
338 );
339 if (!group) return [];
340 return [
341 {
342 ...entry,
343 label: group.displayName,
344 open: () =>
345 context.selectScope({
346 project: group.projectKey,
347 ...(context.search.machine ? { machine: context.search.machine } : {}),
348 }),
349 },
350 ];
351 });
352 }, [context, isProjectScope, scopedKeys]);
353 const renderedReset = unavailable ? null : isProjectScope && scopedKeys.length > 0 ? (
354 source === "project" || source === "mixed" ? (
355 <SettingResetButton
356 label={typeof title === "string" ? title : "override"}
357 tooltip="Reset to inherited value"
358 onClick={() => (onResetOverride ? onResetOverride() : clearOverrides(scopedKeys))}
359 />
360 ) : null
361 ) : (
362 resetAction
363 );
364 const inertControl = (message: string) => (
365 <Tooltip>
366 <TooltipTrigger
367 render={
368 // Focusable so keyboard users can still reach the explanation.
369 <span
370 tabIndex={0}
371 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
372 />
373 }
374 >
375 <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto">
376 {control}
377 </div>
378 </TooltipTrigger>
379 <TooltipPopup side="top" className="max-w-72">
380 {message}
381 </TooltipPopup>
382 </Tooltip>
383 );
384 // A mixed selection keeps the real control with "Mixed" as its placeholder
385 // (the multi-selection inspector convention): the popover shows who has
386 // what, and picking a value applies it to every target.
274 const renderedControl =387 const renderedControl =
275 unavailable && control ? (388 unavailable && control
276 <Tooltip>389 ? inertControl(
277 <TooltipTrigger390 context
278 render={391 ? "Reconnect the selected environment to change this setting."
279 // Focusable so keyboard users can still reach the explanation.392 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
280 <span393 )
281 tabIndex={0}394 : environmentWide && control
282 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"395 ? inertControl("Environment-wide setting. Select an environment to change it.")
283 />396 : control;
284 }397 // Server rows get an indicator beside the title that opens the resolution
285 >398 // chain per target at every scope; client rows keep a plain status only.
286 <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto">399 const customized =
287 {control}400 context !== null &&
288 </div>401 settingKeys.some((key) =>
289 </TooltipTrigger>402 context.targets.some((candidate) => {
290 <TooltipPopup side="top" className="max-w-72">403 const environmentSettings = environmentSettingsById.get(candidate.environmentId);
291 {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}404 return (
292 </TooltipPopup>405 environmentSettings !== undefined &&
293 </Tooltip>406 !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key])
294 ) : (407 );
295 control408 }),
296 );409 );
410 const inheritance: { state: SettingInheritanceState; summary: string } = mixed
411 ? { state: "mixed", summary: "Mixed across selected environments" }
412 : source === "project"
413 ? { state: "overridden", summary: "Overridden for this project" }
414 : source === "environment" && scopedKeys.length > 0
415 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
416 : customized
417 ? { state: "environment", summary: "Set on the environment" }
418 : { state: "default", summary: "Built-in default" };
419 const renderedInheritance =
420 context && serverScoped && settingKeys.length > 0 ? (
421 <SettingInheritance
422 state={inheritance.state}
423 summary={inheritance.summary}
424 targets={context.targets}
425 environments={context.connectedEnvironments}
426 keys={settingKeys}
427 overridingProjects={overridingProjects}
428 onClearOverrides={(entries) => clearProjectOverrides(entries, scopedKeys)}
429 />
430 ) : null;
431 const renderedStatus = status;
297432
298 return (433 return (
299 <div434 <div
...@@ -311,6 +446,11 @@ export function SettingsRow({...@@ -311,6 +446,11 @@ export function SettingsRow({
311 <div className="min-w-0 flex-1 space-y-1">446 <div className="min-w-0 flex-1 space-y-1">
312 <div className="flex min-h-5 items-center gap-1.5">447 <div className="flex min-h-5 items-center gap-1.5">
313 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3>448 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3>
449 {renderedInheritance ? (
450 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
451 {renderedInheritance}
452 </span>
453 ) : null}
314 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">454 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
315 {renderedReset}455 {renderedReset}
316 </span>456 </span>
...@@ -320,7 +460,9 @@ export function SettingsRow({...@@ -320,7 +460,9 @@ export function SettingsRow({
320 {description}460 {description}
321 </p>461 </p>
322 ) : null}462 ) : null}
323 {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}463 {renderedStatus ? (
464 <div className="pt-0.5 text-xs text-muted-foreground">{renderedStatus}</div>
465 ) : null}
324 </div>466 </div>
325 {renderedControl ? (467 {renderedControl ? (
326 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">468 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
apps/web/src/components/settings/settingsScope.test.ts+8-19
...@@ -60,19 +60,12 @@ function group(...@@ -60,19 +60,12 @@ function group(
60const groups = [group("t3code", [first, second, third]), group("other", [other])];60const groups = [group("t3code", [first, second, third]), group("other", [other])];
6161
62describe("settings scope search", () => {62describe("settings scope search", () => {
63 it.each(["device", "all"] as const)(63 it("ignores the retired scope key from older links", () => {
64 "clears narrower targets for an explicit %s selection",64 expect(validateSettingsScopeSearch({ scope: "device", project: "t3code" })).toEqual({
65 (scope) => {65 project: "t3code",
66 expect(66 });
67 validateSettingsScopeSearch({67 expect(validateSettingsScopeSearch({ scope: "all" })).toEqual({});
68 scope,68 });
69 project: "t3code",
70 machine: laptopId,
71 checkout: first.physicalProjectKey,
72 }),
73 ).toEqual({ scope });
74 },
75 );
7669
77 it("retains legacy project and machine links without inventing an explicit broad scope", () => {70 it("retains legacy project and machine links without inventing an explicit broad scope", () => {
78 expect(71 expect(
...@@ -96,15 +89,11 @@ describe("settings scope search", () => {...@@ -96,15 +89,11 @@ describe("settings scope search", () => {
96});89});
9790
98describe("settings scope resolution", () => {91describe("settings scope resolution", () => {
99 it("distinguishes this device from the default aggregate target", () => {92 it("defaults to every environment with no project", () => {
100 expect(resolveSettingsScope({}, groups, environments)).toMatchObject({93 expect(resolveSettingsScope({}, groups, environments)).toMatchObject({
101 kind: "all",94 kind: "all",
102 environmentIds: [laptopId, serverId],
103 });
104 expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({
105 kind: "device",
106 members: [],95 members: [],
107 environmentIds: [],96 environmentIds: [laptopId, serverId],
108 });97 });
109 });98 });
11099
apps/web/src/components/settings/settingsScope.ts+8-15
...@@ -6,8 +6,13 @@ import type {...@@ -6,8 +6,13 @@ import type {
6} from "../../sidebarProjectGrouping";6} from "../../sidebarProjectGrouping";
7import type { EnvironmentPresentation } from "../../state/environments";7import type { EnvironmentPresentation } from "../../state/environments";
88
9/**
10 * Two axes. `machine` narrows the environment axis (absent = all
11 * environments); `project` and `checkout` narrow the project axis (absent =
12 * environment defaults). Device-local preferences are not a scope: they
13 * render regardless of the selection because they never touch a server.
14 */
9export interface SettingsScopeSearch {15export interface SettingsScopeSearch {
10 scope?: "device" | "all" | undefined;
11 project?: string | undefined;16 project?: string | undefined;
12 machine?: string | undefined;17 machine?: string | undefined;
13 checkout?: string | undefined;18 checkout?: string | undefined;
...@@ -21,7 +26,7 @@ type ScopeTargets = {...@@ -21,7 +26,7 @@ type ScopeTargets = {
2126
22export type ResolvedSettingsScope = ScopeTargets &27export type ResolvedSettingsScope = ScopeTargets &
23 (28 (
24 | { kind: "device" | "all" }29 | { kind: "all" }
25 | { kind: "environment"; environmentId: EnvironmentId }30 | { kind: "environment"; environmentId: EnvironmentId }
26 | {31 | {
27 kind: "project";32 kind: "project";
...@@ -41,9 +46,8 @@ export type ResolvedSettingsScope = ScopeTargets &...@@ -41,9 +46,8 @@ export type ResolvedSettingsScope = ScopeTargets &
41 }46 }
42 );47 );
4348
44/** Explicit broad targets replace narrower selections; stale IDs remain visible to the resolver. */49/** Stale IDs remain visible to the resolver so a removed target reads as unavailable, not as "all". */
45export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch {50export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch {
46 if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope };
47 const stringValue = (value: unknown) =>51 const stringValue = (value: unknown) =>
48 typeof value === "string" && value.trim().length > 0 ? value : undefined;52 typeof value === "string" && value.trim().length > 0 ? value : undefined;
49 const project = stringValue(raw.project);53 const project = stringValue(raw.project);
...@@ -74,17 +78,6 @@ export function resolveSettingsScope(...@@ -74,17 +78,6 @@ export function resolveSettingsScope(
74 environmentIds: [],78 environmentIds: [],
75 });79 });
7680
77 if (search.scope === "device") {
78 return { kind: "device", label: "This device", members: [], environmentIds: [] };
79 }
80 if (search.scope === "all") {
81 return {
82 kind: "all",
83 label: "All environments",
84 members: [],
85 environmentIds: environments.map((environment) => environment.environmentId),
86 };
87 }
88 if (search.checkout && !search.project) {81 if (search.checkout && !search.project) {
89 return unavailable("project-required", "Select a project to choose one of its checkouts.");82 return unavailable("project-required", "Select a project to choose one of its checkouts.");
90 }83 }
apps/web/src/components/settings/settingsScopeAxis.test.ts created+88
...@@ -0,0 +1,88 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 environmentAxisValue,
6 projectAxisValue,
7 selectEnvironmentAxis,
8 selectProjectAxis,
9 settingsScopeEnvironmentLabel,
10} from "./settingsScopeAxis";
11
12const first = {
13 environmentId: EnvironmentId.make("first"),
14 label: "Development",
15 displayUrl: "https://first.example.com",
16};
17const second = {
18 environmentId: EnvironmentId.make("second"),
19 label: "Development",
20 displayUrl: "https://second.example.com",
21};
22
23describe("settings scope environment labels", () => {
24 it("distinguishes same-name environments by address", () => {
25 const environments = [first, second];
26 expect(
27 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
28 ).toEqual([
29 "Development · https://first.example.com",
30 "Development · https://second.example.com",
31 ]);
32 });
33
34 it("falls back to environment IDs when duplicate names have no display URL", () => {
35 const environments = [first, second].map((environment) => ({
36 ...environment,
37 displayUrl: null,
38 }));
39 expect(
40 environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)),
41 ).toEqual(["Development · first", "Development · second"]);
42 });
43
44 it("keeps unique names compact and removes disambiguation after a rename", () => {
45 expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development");
46 expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe(
47 "Development",
48 );
49 });
50});
51
52describe("settings scope axes", () => {
53 it("maps each axis to its search key and back", () => {
54 expect(projectAxisValue({})).toBe("all");
55 expect(projectAxisValue({ project: "app" })).toBe("app");
56 expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({
57 project: "app",
58 machine: "second",
59 });
60 expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({
61 machine: "second",
62 });
63 expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({
64 project: "app",
65 machine: "first",
66 });
67 expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({
68 project: "app",
69 });
70 });
71
72 it("drops a checkout narrowing from older links when either axis changes", () => {
73 const checkout = { project: "app", checkout: "app@first", machine: "first" };
74 expect(selectEnvironmentAxis(checkout, "second")).toEqual({
75 project: "app",
76 machine: "second",
77 });
78 expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" });
79 });
80});
81
82describe("environmentAxisValue", () => {
83 it("shows the checkout's environment for a legacy checkout link", () => {
84 expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop");
85 expect(environmentAxisValue({ project: "p" }, null)).toBe("all");
86 expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk");
87 });
88});
apps/web/src/components/settings/settingsScopeAxis.ts created+55
...@@ -0,0 +1,55 @@
1import type { EnvironmentPresentation } from "../../state/environments";
2import type { SettingsScopeSearch } from "./settingsScope";
3
4type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;
5
6export function settingsScopeEnvironmentLabel(
7 environment: ScopeEnvironment,
8 environments: readonly ScopeEnvironment[],
9) {
10 const duplicate = environments.some(
11 (other) =>
12 other.environmentId !== environment.environmentId && other.label === environment.label,
13 );
14 return duplicate
15 ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}`
16 : environment.label;
17}
18
19export const ALL_ENVIRONMENTS_VALUE = "all";
20export const ALL_PROJECTS_VALUE = "all";
21
22/**
23 * The environment axis: `all` or an environment id. A legacy checkout link
24 * without `machine` still names one environment, which the resolver supplies.
25 */
26export function environmentAxisValue(
27 search: SettingsScopeSearch,
28 resolvedEnvironmentId?: string | null,
29): string {
30 return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE;
31}
32
33/** The project axis: `all` or a project key. */
34export function projectAxisValue(search: SettingsScopeSearch): string {
35 return search.project ?? ALL_PROJECTS_VALUE;
36}
37
38/** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */
39export function selectEnvironmentAxis(
40 search: SettingsScopeSearch,
41 value: string,
42): SettingsScopeSearch {
43 const next: SettingsScopeSearch = {};
44 if (search.project) next.project = search.project;
45 if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value;
46 return next;
47}
48
49/** Choosing a project keeps the environment axis. */
50export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch {
51 const next: SettingsScopeSearch = {};
52 if (value !== ALL_PROJECTS_VALUE) next.project = value;
53 if (search.machine) next.machine = search.machine;
54 return next;
55}
apps/web/src/components/settings/settingsScopeNavigation.test.ts created+247
...@@ -0,0 +1,247 @@
1import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRoute,
6 createRouter,
7 redirect,
8} from "@tanstack/react-router";
9import { describe, expect, it } from "vite-plus/test";
10
11import { resolveSettingsScope } from "./settingsScope";
12import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation";
13
14const checkoutSearch = {
15 project: "repository:t3code",
16 machine: "remote-server",
17 checkout: "remote-server:/home/user/T3 Code",
18};
19
20function createSettingsRouter(initialEntry = "/settings/general") {
21 const root = createRootRoute();
22 const settings = createRoute({
23 getParentRoute: () => root,
24 path: "settings",
25 validateSearch: validateSettingsRouteSearch,
26 search: { middlewares: [retainSettingsScope] },
27 beforeLoad: ({ location }) => {
28 if (location.pathname === "/settings") {
29 throw redirect({ to: "/settings/general", replace: true });
30 }
31 },
32 });
33 const general = createRoute({ getParentRoute: () => settings, path: "general" });
34 const projects = createRoute({ getParentRoute: () => settings, path: "projects" });
35 const integrations = createRoute({ getParentRoute: () => settings, path: "integrations" });
36 const sourceControl = createRoute({ getParentRoute: () => settings, path: "source-control" });
37 const providers = createRoute({
38 getParentRoute: () => settings,
39 path: "providers",
40 validateSearch: (raw: Record<string, unknown>) => ({
41 ...(typeof raw.environmentId === "string" && raw.environmentId.trim()
42 ? { environmentId: EnvironmentId.make(raw.environmentId) }
43 : {}),
44 ...(typeof raw.instanceId === "string" && raw.instanceId.trim()
45 ? { instanceId: ProviderInstanceId.make(raw.instanceId) }
46 : {}),
47 }),
48 });
49 const legacyProject = createRoute({
50 getParentRoute: () => root,
51 path: "projects/$projectKey",
52 beforeLoad: ({ params }) => {
53 throw redirect({
54 to: "/settings/projects",
55 search: { project: params.projectKey, machine: undefined },
56 replace: true,
57 });
58 },
59 });
60 return createRouter({
61 routeTree: root.addChildren([
62 settings.addChildren([general, projects, integrations, sourceControl, providers]),
63 legacyProject,
64 ]),
65 history: createMemoryHistory({ initialEntries: [initialEntry] }),
66 });
67}
68
69describe("settings scope navigation", () => {
70 it("replaces the default scope with an explicit environment, then replaces it with a project", async () => {
71 const router = createSettingsRouter();
72 await router.load();
73 await router.navigate({
74 to: "/settings/general",
75 search: { machine: "remote-server" },
76 hash: "",
77 });
78 expect(router.state.location.search).toEqual({ machine: "remote-server" });
79 await router.navigate({ to: "/settings/projects", search: { project: "another-project" } });
80 expect(router.state.location.search).toEqual({ project: "another-project" });
81 });
82
83 it("clears a checkout when selecting all environments and all projects", async () => {
84 const router = createSettingsRouter();
85 await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" });
86 // The scope selects send every axis explicitly so "all" does not read as "unchanged".
87 await router.navigate({
88 to: "/settings/general",
89 search: { project: undefined, machine: undefined, checkout: undefined },
90 hash: "",
91 });
92 expect(router.state.location.search).toEqual({});
93 expect(router.state.location.hash).toBe("");
94 });
95
96 it("preserves the checkout through category and settings-search navigation", async () => {
97 const router = createSettingsRouter();
98 await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "new-threads" });
99 await router.navigate({ to: "/settings/integrations", hash: "", replace: true });
100 expect(router.state.location.search).toEqual(checkoutSearch);
101 expect(router.state.location.hash).toBe("");
102 await router.navigate({ to: "/settings/source-control", hash: "source-control-writing-style" });
103 expect(router.state.location.search).toEqual(checkoutSearch);
104 expect(router.state.location.hash).toBe("source-control-writing-style");
105 await router.navigate({ to: "/settings/projects", hash: "project-defaults" });
106 expect(router.state.location.search).toEqual(checkoutSearch);
107 });
108
109 it.each(["/settings/projects", "/settings/integrations", "/settings/source-control"] as const)(
110 "keeps %s when regrouping or selecting a target from the shared settings layout",
111 async (to) => {
112 const router = createSettingsRouter();
113 await router.navigate({ to, search: checkoutSearch });
114
115 const regroupedCheckout = { ...checkoutSearch, project: "separate:t3code" };
116 await router.navigate({
117 from: "/settings",
118 to: router.state.location.pathname,
119 search: () => regroupedCheckout,
120 replace: true,
121 hashScrollIntoView: false,
122 });
123 expect(router.state.location.pathname).toBe(to);
124 expect(router.state.location.search).toEqual(regroupedCheckout);
125 expect(router.state.redirect).toBeUndefined();
126
127 await router.navigate({
128 from: "/settings",
129 to: router.state.location.pathname,
130 search: () => ({ machine: "another-server" }),
131 hash: "",
132 resetScroll: false,
133 });
134 expect(router.state.location.pathname).toBe(to);
135 expect(router.state.location.search).toEqual({ machine: "another-server" });
136 expect(router.state.location.hash).toBe("");
137
138 const selectedCheckout = {
139 project: "another-project",
140 machine: "another-server",
141 checkout: "another-server:/home/user/Another checkout",
142 };
143 await router.navigate({
144 from: "/settings",
145 to: router.state.location.pathname,
146 search: () => selectedCheckout,
147 hash: "requested-setting",
148 });
149 expect(router.state.location.pathname).toBe(to);
150 expect(router.state.location.search).toEqual(selectedCheckout);
151 expect(router.state.location.hash).toBe("requested-setting");
152 },
153 );
154
155 it("honors an explicit provider environment and drops its instance on category navigation", async () => {
156 const router = createSettingsRouter();
157 await router.navigate({ to: "/settings/general", search: checkoutSearch });
158 await router.navigate({
159 to: "/settings/providers",
160 search: {
161 environmentId: EnvironmentId.make("provider-server"),
162 instanceId: ProviderInstanceId.make("codex-work"),
163 },
164 });
165 expect(router.state.location.search).toEqual({
166 machine: "provider-server",
167 environmentId: "provider-server",
168 instanceId: "codex-work",
169 });
170 await router.navigate({ to: "/settings/general", hash: "" });
171 expect(router.state.location.search).toEqual({ machine: "provider-server" });
172 });
173
174 it("preserves the environment from an initially loaded legacy provider URL", async () => {
175 const router = createSettingsRouter(
176 "/settings/providers?environmentId=provider-server&instanceId=codex-work",
177 );
178 await router.load();
179 await router.navigate({ to: "/settings/general" });
180 expect(router.state.location.search).toEqual({ machine: "provider-server" });
181 });
182
183 it("retains an explicit unavailable target rather than reviving the previous environment", async () => {
184 const router = createSettingsRouter();
185 await router.navigate({ to: "/settings/general", search: { machine: "online" } });
186 await router.navigate({ to: "/settings/general", search: { machine: "removed" } });
187 expect(router.state.location.search).toEqual({ machine: "removed" });
188 expect(
189 resolveSettingsScope(
190 router.state.location.search,
191 [],
192 [{ environmentId: EnvironmentId.make("online"), label: "Online" }],
193 ),
194 ).toMatchObject({
195 kind: "unavailable",
196 reason: "environment-missing",
197 environmentIds: [],
198 });
199 });
200
201 it("respects explicit clearing keys instead of restoring the previous checkout", async () => {
202 const router = createSettingsRouter();
203 await router.navigate({ to: "/settings/general", search: checkoutSearch });
204 await router.navigate({
205 to: "/settings/projects",
206 search: { project: "different-project", machine: undefined },
207 });
208 expect(router.state.location.search).toEqual({ project: "different-project" });
209 });
210
211 it("preserves escaped checkout identifiers across reload and browser history", async () => {
212 const router = createSettingsRouter();
213 await router.navigate({ to: "/settings/general", search: checkoutSearch });
214 const checkoutHref = router.state.location.href;
215 await router.navigate({ to: "/settings/general", search: { machine: "another" } });
216 router.history.back();
217 await router.load();
218 expect(router.state.location.search).toEqual(checkoutSearch);
219 const reloaded = createSettingsRouter(checkoutHref);
220 await reloaded.load();
221 expect(reloaded.state.location.search).toEqual(checkoutSearch);
222 await reloaded.navigate({ to: "/settings/integrations", hash: "agent-browser-access" });
223 expect(reloaded.state.location.search).toEqual(checkoutSearch);
224 });
225
226 it("redirects legacy project links without carrying the prior checkout scope", async () => {
227 const router = createSettingsRouter();
228 await router.navigate({ to: "/settings/general", search: checkoutSearch });
229 await router.navigate({
230 to: "/projects/$projectKey",
231 params: { projectKey: "legacy-project" },
232 });
233 expect(router.state.redirect).not.toBeUndefined();
234 await router.navigate(router.state.redirect!.options);
235 expect(router.state.location.pathname).toBe("/settings/projects");
236 expect(router.state.location.search).toEqual({ project: "legacy-project" });
237 });
238
239 it("keeps scope through the settings index redirect", async () => {
240 const router = createSettingsRouter();
241 await router.navigate({ to: "/settings", search: { machine: "remote-server" } });
242 expect(router.state.redirect).not.toBeUndefined();
243 await router.navigate(router.state.redirect!.options);
244 expect(router.state.location.pathname).toBe("/settings/general");
245 expect(router.state.location.search).toEqual({ machine: "remote-server" });
246 });
247});
apps/web/src/components/settings/settingsScopeNavigation.ts created+32
...@@ -0,0 +1,32 @@
1import type { SearchMiddleware } from "@tanstack/react-router";
2
3import { validateSettingsScopeSearch, type SettingsScopeSearch } from "./settingsScope";
4
5/** Accept legacy provider links without replacing an explicit settings scope. */
6export function validateSettingsRouteSearch(raw: Record<string, unknown>) {
7 return validateSettingsScopeSearch(
8 typeof raw.environmentId === "string" && raw.machine === undefined && raw.project === undefined
9 ? { ...raw, machine: raw.environmentId }
10 : raw,
11 );
12}
13
14const SCOPE_KEYS = [
15 "project",
16 "machine",
17 "checkout",
18] as const satisfies readonly (keyof SettingsScopeSearch)[];
19const TARGET_INPUT_KEYS = [...SCOPE_KEYS, "environmentId"];
20
21/**
22 * Category links keep the target, while an explicit target replaces the entire
23 * previous selection. `environmentId` is the legacy provider deep-link target.
24 */
25export const retainSettingsScope: SearchMiddleware<SettingsScopeSearch> = ({ search, next }) => {
26 const result = next(search);
27 if (TARGET_INPUT_KEYS.some((key) => Object.hasOwn(result, key))) return result;
28 const previousScope = Object.fromEntries(
29 SCOPE_KEYS.filter((key) => search[key] !== undefined).map((key) => [key, search[key]]),
30 );
31 return { ...previousScope, ...result };
32};
apps/web/src/components/settings/settingsSearch.test.ts+197-2
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
23
3import {4import {
4 filterAvailableSettingsSearchItems,5 filterAvailableSettingsSearchItems,
6 getSettingsSearchTargetScope,
7 getThreadAutoSettlementSearchAvailability,
8 isSettingsOverviewVisible,
9 isSettingsSearchScopeAvailable,
5 searchableSetting,10 searchableSetting,
6 searchSettings,11 searchSettings,
7 SETTINGS_SEARCH_ITEMS,12 SETTINGS_SEARCH_ITEMS,
...@@ -143,7 +148,7 @@ describe("searchSettings", () => {...@@ -143,7 +148,7 @@ describe("searchSettings", () => {
143 it("hides settings whose controls are unavailable", () => {148 it("hides settings whose controls are unavailable", () => {
144 const available = filterAvailableSettingsSearchItems({149 const available = filterAvailableSettingsSearchItems({
145 hasCloudPublicConfig: false,150 hasCloudPublicConfig: false,
146 hasPrimaryEnvironment: false,151 hasEnvironment: false,
147 hasProviderSettingsEnvironment: false,152 hasProviderSettingsEnvironment: false,
148 canManageLocalBackend: false,153 canManageLocalBackend: false,
149 isWslSettingsRowVisible: false,154 isWslSettingsRowVisible: false,
...@@ -171,7 +176,7 @@ describe("searchSettings", () => {...@@ -171,7 +176,7 @@ describe("searchSettings", () => {
171 it("shows automatic settlement settings when the server supports them", () => {176 it("shows automatic settlement settings when the server supports them", () => {
172 const available = filterAvailableSettingsSearchItems({177 const available = filterAvailableSettingsSearchItems({
173 hasCloudPublicConfig: false,178 hasCloudPublicConfig: false,
174 hasPrimaryEnvironment: false,179 hasEnvironment: false,
175 hasProviderSettingsEnvironment: false,180 hasProviderSettingsEnvironment: false,
176 canManageLocalBackend: false,181 canManageLocalBackend: false,
177 isWslSettingsRowVisible: false,182 isWslSettingsRowVisible: false,
...@@ -256,4 +261,194 @@ describe("searchSettings", () => {...@@ -256,4 +261,194 @@ describe("searchSettings", () => {
256 targetId: "browser-profiles",261 targetId: "browser-profiles",
257 });262 });
258 });263 });
264
265 it.each([
266 ["default model", "default-model", "/settings/general"],
267 ["new threads", "new-threads", "/settings/general"],
268 ["agent browser access", "agent-browser-access", "/settings/integrations"],
269 ["automatically pull", "automatic-pull", "/settings/source-control"],
270 ["actions", "project-actions", "/settings/projects"],
271 ["project overview", "project-overview", "/settings/projects"],
272 ])("routes %s to its owning category", (query, id, to) => {
273 expect(searchSettings(query)[0]).toMatchObject({ id, to });
274 });
275
276 it("keeps environment settings discoverable without a primary environment", () => {
277 const available = filterAvailableSettingsSearchItems({
278 hasCloudPublicConfig: false,
279 hasEnvironment: true,
280 hasProviderSettingsEnvironment: true,
281 canManageLocalBackend: false,
282 isWslSettingsRowVisible: false,
283 hasThreadAutoSettlement: true,
284 });
285 expect(searchSettings("writing style", available)[0]?.id).toBe("source-control-writing-style");
286 expect(searchSettings("auto-settle", available)).toHaveLength(3);
287 });
288});
289
290describe("settings search targets", () => {
291 it.each([
292 "auto-settle-inactive-threads",
293 "auto-settle-merged-threads",
294 "days-before-auto-settle",
295 ])("retains the capability requirement for %s", (targetId) => {
296 expect(getSettingsSearchTargetScope(targetId)).toMatchObject({
297 scope: "project-defaults",
298 requiresThreadAutoSettlement: true,
299 });
300 });
301
302 it("treats device-local rows as reachable from every selection", () => {
303 const setting = getSettingsSearchTargetScope("time-format")!;
304 expect(setting).toEqual({ title: "Time format", scope: null });
305 expect(isSettingsSearchScopeAvailable(setting.scope, "project")).toBe(true);
306 expect(isSettingsSearchScopeAvailable(setting.scope, "all")).toBe(true);
307 expect(getSettingsSearchTargetScope("appearance")).toMatchObject({ scope: null });
308 expect(getSettingsSearchTargetScope("missing-setting")).toBeNull();
309 });
310
311 it.each(["all", "environment", "project", "checkout"] as const)(
312 "makes browser access editable at the %s scope",
313 (kind) => {
314 const setting = getSettingsSearchTargetScope("agent-browser-access")!;
315 expect(isSettingsSearchScopeAvailable(setting.scope, kind)).toBe(true);
316 expect(isSettingsSearchScopeAvailable(setting.scope, "unavailable")).toBe(false);
317 },
318 );
319
320 it("lets project-scopable rows resolve at every server-backed scope", () => {
321 const model = getSettingsSearchTargetScope("text-generation-model")!;
322 expect(isSettingsSearchScopeAvailable(model.scope, "all")).toBe(true);
323 expect(isSettingsSearchScopeAvailable(model.scope, "environment")).toBe(true);
324 expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true);
325 });
326
327 it("reaches source control discovery and git fetch interval from the default scope", () => {
328 for (const id of ["source-control", "git-fetch-interval"]) {
329 const item = getSettingsSearchTargetScope(id)!;
330 expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true);
331 expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true);
332 expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(false);
333 }
334 });
335
336 it("keeps environment-wide settings out of project scopes", () => {
337 const updates = getSettingsSearchTargetScope("provider-update-checks")!;
338 expect(updates.scope).toBe("environment-defaults");
339 expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);
340 expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);
341 expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);
342 const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!;
343 expect(streaming.scope).toBe("project-defaults");
344 expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);
345 for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) {
346 expect(getSettingsSearchTargetScope(id)!.scope).toBeNull();
347 }
348 });
349});
350
351describe("auto-settlement search availability", () => {
352 function environment(id: string, { connected = true, loaded = true, supported = true } = {}) {
353 return {
354 environmentId: EnvironmentId.make(id),
355 connection: { phase: connected ? ("connected" as const) : ("offline" as const) },
356 serverConfig: loaded
357 ? { environment: { capabilities: { threadAutoSettlement: supported } } }
358 : null,
359 };
360 }
361
362 const capable = environment("capable");
363 const unsupported = environment("unsupported", { supported: false });
364 const offline = environment("offline", { connected: false });
365 const loading = environment("loading", { loaded: false });
366 const environments = [capable, unsupported, offline, loading];
367
368 it("keeps results discoverable when one connected environment supports them", () => {
369 const availability = getThreadAutoSettlementSearchAvailability(environments);
370 expect(availability.eligibleEnvironmentIds).toEqual([capable.environmentId]);
371 const items = filterAvailableSettingsSearchItems({
372 hasCloudPublicConfig: false,
373 hasEnvironment: true,
374 hasProviderSettingsEnvironment: true,
375 canManageLocalBackend: false,
376 isWslSettingsRowVisible: false,
377 hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,
378 });
379 expect(searchSettings("auto-settle", items).map((item) => item.id)).toEqual([
380 "auto-settle-inactive-threads",
381 "auto-settle-merged-threads",
382 "days-before-auto-settle",
383 ]);
384 });
385
386 it("offers only capable environments when an aggregate has mixed capabilities", () => {
387 expect(
388 getThreadAutoSettlementSearchAvailability(environments, {
389 kind: "all",
390 environmentIds: environments.map((entry) => entry.environmentId),
391 }),
392 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
393 });
394
395 it("allows a capable named environment regardless of other environments' capabilities", () => {
396 expect(
397 getThreadAutoSettlementSearchAvailability(environments, {
398 kind: "environment",
399 environmentIds: [capable.environmentId],
400 }).isTargetAvailable,
401 ).toBe(true);
402 });
403
404 it.each([unsupported, offline, loading])(
405 "does not render the target on $environmentId or silently fall back",
406 (selected) => {
407 expect(
408 getThreadAutoSettlementSearchAvailability(environments, {
409 kind: "environment",
410 environmentIds: [selected.environmentId],
411 }),
412 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
413 },
414 );
415
416 it("offers a capable environment instead of a dead target at an unavailable scope", () => {
417 expect(
418 getThreadAutoSettlementSearchAvailability(environments, {
419 kind: "unavailable",
420 environmentIds: [capable.environmentId],
421 }),
422 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
423 });
424
425 it("ignores offline and unloaded targets when all connected targets support the setting", () => {
426 const selected = [capable, offline, loading];
427 expect(
428 getThreadAutoSettlementSearchAvailability(selected, {
429 kind: "all",
430 environmentIds: selected.map((entry) => entry.environmentId),
431 }).isTargetAvailable,
432 ).toBe(true);
433 });
434
435 it("offers no unavailable environments when none can render the setting", () => {
436 const selected = [unsupported, offline, loading];
437 expect(
438 getThreadAutoSettlementSearchAvailability(selected, {
439 kind: "all",
440 environmentIds: selected.map((entry) => entry.environmentId),
441 }),
442 ).toEqual({ eligibleEnvironmentIds: [], isTargetAvailable: false });
443 expect(getThreadAutoSettlementSearchAvailability([]).eligibleEnvironmentIds).toEqual([]);
444 });
445});
446
447describe("settings sidebar scope", () => {
448 it("shows Overview only for project and checkout targets", () => {
449 expect(isSettingsOverviewVisible({})).toBe(false);
450 expect(isSettingsOverviewVisible({ machine: "remote" })).toBe(false);
451 expect(isSettingsOverviewVisible({ project: "project" })).toBe(true);
452 expect(isSettingsOverviewVisible({ project: "project", checkout: "checkout" })).toBe(true);
453 });
259});454});
apps/web/src/components/settings/settingsSearch.ts+187-14
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import { isElectron } from "~/env";1import { isElectron } from "~/env";
2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
3import type { EnvironmentId } from "@t3tools/contracts";
4import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
5import {
6 validateSettingsScopeSearch,
7 type ResolvedSettingsScope,
8 type SettingsScopeSearch,
9} from "./settingsScope";
310
4export type SettingsPath =11export type SettingsPath =
5 | "/settings/projects"12 | "/settings/projects"
...@@ -13,6 +20,19 @@ export type SettingsPath =...@@ -13,6 +20,19 @@ export type SettingsPath =
13 | "/settings/connections"20 | "/settings/connections"
14 | "/settings/archived";21 | "/settings/archived";
1522
23/**
24 * Where a setting can be edited. Device-local rows have no scope: they render
25 * at every selection. `project-defaults` rows accept project overrides, so
26 * they are reachable from any server-backed selection.
27 */
28export type SettingsSearchScope =
29 | "environment"
30 | "environment-defaults"
31 | "project-defaults"
32 | "project"
33 | "checkout"
34 | "connections";
35
16export interface SettingsSearchItem {36export interface SettingsSearchItem {
17 readonly id: string;37 readonly id: string;
18 readonly title: string;38 readonly title: string;
...@@ -20,6 +40,7 @@ export interface SettingsSearchItem {...@@ -20,6 +40,7 @@ export interface SettingsSearchItem {
20 readonly targetId?: string;40 readonly targetId?: string;
21 /** Descriptions, option labels, and aliases people may remember instead of the title. */41 /** Descriptions, option labels, and aliases people may remember instead of the title. */
22 readonly searchTerms?: ReadonlyArray<string>;42 readonly searchTerms?: ReadonlyArray<string>;
43 readonly scope?: SettingsSearchScope;
23 // Its row only renders in the desktop app, so a browser result would land on44 // Its row only renders in the desktop app, so a browser result would land on
24 // an anchor that isn't there.45 // an anchor that isn't there.
25 readonly desktopOnly?: boolean;46 readonly desktopOnly?: boolean;
...@@ -28,7 +49,7 @@ export interface SettingsSearchItem {...@@ -28,7 +49,7 @@ export interface SettingsSearchItem {
28 // not expose a result that points to a missing anchor.49 // not expose a result that points to a missing anchor.
29 readonly windowsOnly?: boolean;50 readonly windowsOnly?: boolean;
30 readonly cloudOnly?: boolean;51 readonly cloudOnly?: boolean;
31 readonly primaryOnly?: boolean;52 readonly environmentOnly?: boolean;
32 readonly providerSettingsOnly?: boolean;53 readonly providerSettingsOnly?: boolean;
33 readonly localBackendManagementOnly?: boolean;54 readonly localBackendManagementOnly?: boolean;
34 readonly wslAvailableOnly?: boolean;55 readonly wslAvailableOnly?: boolean;
...@@ -37,7 +58,7 @@ export interface SettingsSearchItem {...@@ -37,7 +58,7 @@ export interface SettingsSearchItem {
3758
38export interface SettingsSearchAvailability {59export interface SettingsSearchAvailability {
39 readonly hasCloudPublicConfig: boolean;60 readonly hasCloudPublicConfig: boolean;
40 readonly hasPrimaryEnvironment: boolean;61 readonly hasEnvironment: boolean;
41 readonly hasProviderSettingsEnvironment: boolean;62 readonly hasProviderSettingsEnvironment: boolean;
42 readonly canManageLocalBackend: boolean;63 readonly canManageLocalBackend: boolean;
43 readonly isWslSettingsRowVisible: boolean;64 readonly isWslSettingsRowVisible: boolean;
...@@ -49,9 +70,9 @@ export interface SettingsSearchAvailability {...@@ -49,9 +70,9 @@ export interface SettingsSearchAvailability {
49 * subtitles both render from this record, so each label exists once.70 * subtitles both render from this record, so each label exists once.
50 */71 */
51export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {72export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
73 "/settings/projects": "Project",
52 "/settings/general": "General",74 "/settings/general": "General",
53 "/settings/appearance": "Appearance",75 "/settings/appearance": "Appearance",
54 "/settings/projects": "Projects",
55 "/settings/keybindings": "Keybindings",76 "/settings/keybindings": "Keybindings",
56 "/settings/snap-shot": "SnapShots",77 "/settings/snap-shot": "SnapShots",
57 "/settings/providers": "Providers",78 "/settings/providers": "Providers",
...@@ -70,10 +91,22 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -70,10 +91,22 @@ export const SETTINGS_SEARCH_ITEMS = [
70 {91 {
71 id: "project-defaults",92 id: "project-defaults",
72 title: "Project defaults and overrides",93 title: "Project defaults and overrides",
94 to: "/settings/general",
95 scope: "project-defaults",
96 searchTerms: ["model workspace environments projects inheritance checkout"],
97 },
98 {
99 id: "project-overview",
100 title: "Project overview",
73 to: "/settings/projects",101 to: "/settings/projects",
74 searchTerms: [102 searchTerms: ["name icon emoji image checkout remove delete"],
75 "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts",103 },
76 ],104 {
105 id: "default-model",
106 title: "Default model",
107 to: "/settings/general",
108 scope: "project-defaults",
109 searchTerms: ["new thread project provider reasoning effort"],
77 },110 },
78 {111 {
79 id: "color-scheme",112 id: "color-scheme",
...@@ -174,6 +207,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -174,6 +207,7 @@ export const SETTINGS_SEARCH_ITEMS = [
174 to: "/settings/general",207 to: "/settings/general",
175 searchTerms: ["sidebar inactivity days no activity automatically"],208 searchTerms: ["sidebar inactivity days no activity automatically"],
176 requiresThreadAutoSettlement: true,209 requiresThreadAutoSettlement: true,
210 scope: "project-defaults",
177 },211 },
178 {212 {
179 id: "auto-settle-merged-threads",213 id: "auto-settle-merged-threads",
...@@ -181,6 +215,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -181,6 +215,7 @@ export const SETTINGS_SEARCH_ITEMS = [
181 to: "/settings/general",215 to: "/settings/general",
182 searchTerms: ["pull request merge closed automatically sidebar"],216 searchTerms: ["pull request merge closed automatically sidebar"],
183 requiresThreadAutoSettlement: true,217 requiresThreadAutoSettlement: true,
218 scope: "project-defaults",
184 },219 },
185 {220 {
186 id: "days-before-auto-settle",221 id: "days-before-auto-settle",
...@@ -189,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -189,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [
189 targetId: "auto-settle-inactive-threads",224 targetId: "auto-settle-inactive-threads",
190 searchTerms: ["thread timeout activity sidebar"],225 searchTerms: ["thread timeout activity sidebar"],
191 requiresThreadAutoSettlement: true,226 requiresThreadAutoSettlement: true,
227 scope: "project-defaults",
192 },228 },
193 {229 {
194 id: "time-format",230 id: "time-format",
...@@ -231,11 +267,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -231,11 +267,13 @@ export const SETTINGS_SEARCH_ITEMS = [
231 title: "Provider update checks",267 title: "Provider update checks",
232 to: "/settings/general",268 to: "/settings/general",
233 searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],269 searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
270 scope: "environment-defaults",
234 },271 },
235 {272 {
236 id: "continue-threads-after-server-update",273 id: "continue-threads-after-server-update",
237 title: "Continue threads after restarts",274 title: "Continue threads after restarts",
238 to: "/settings/general",275 to: "/settings/general",
276 scope: "project-defaults",
239 searchTerms: [277 searchTerms: [
240 "resume running active interrupted work restart reboot machine crash desktop update automatically",278 "resume running active interrupted work restart reboot machine crash desktop update automatically",
241 ],279 ],
...@@ -244,6 +282,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -244,6 +282,7 @@ export const SETTINGS_SEARCH_ITEMS = [
244 id: "background-activity",282 id: "background-activity",
245 title: "Background activity",283 title: "Background activity",
246 to: "/settings/general",284 to: "/settings/general",
285 scope: "environment-defaults",
247 searchTerms: [286 searchTerms: [
248 "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy",287 "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy",
249 ],288 ],
...@@ -251,19 +290,22 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -251,19 +290,22 @@ export const SETTINGS_SEARCH_ITEMS = [
251 {290 {
252 id: "new-threads",291 id: "new-threads",
253 title: "New threads",292 title: "New threads",
254 to: "/settings/projects",293 to: "/settings/general",
294 scope: "project-defaults",
255 searchTerms: ["default workspace mode draft local worktree"],295 searchTerms: ["default workspace mode draft local worktree"],
256 },296 },
257 {297 {
258 id: "start-from-origin",298 id: "start-from-origin",
259 title: "Start from origin",299 title: "Start from origin",
260 to: "/settings/general",300 to: "/settings/general",
301 scope: "project-defaults",
261 searchTerms: ["new worktrees latest matching remote branch local"],302 searchTerms: ["new worktrees latest matching remote branch local"],
262 },303 },
263 {304 {
264 id: "add-project-starts-in",305 id: "add-project-starts-in",
265 title: "Add project starts in",306 title: "Add project starts in",
266 to: "/settings/general",307 to: "/settings/general",
308 scope: "environment-defaults",
267 searchTerms: ["base directory folder browser path home"],309 searchTerms: ["base directory folder browser path home"],
268 },310 },
269 {311 {
...@@ -295,6 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -295,6 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [
295 id: "text-generation-model",337 id: "text-generation-model",
296 title: "Text generation model",338 title: "Text generation model",
297 to: "/settings/general",339 to: "/settings/general",
340 scope: "project-defaults",
298 searchTerms: ["generated thread titles source control content default provider"],341 searchTerms: ["generated thread titles source control content default provider"],
299 },342 },
300 {343 {
...@@ -319,6 +362,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -319,6 +362,7 @@ export const SETTINGS_SEARCH_ITEMS = [
319 id: "legacy-token-streaming",362 id: "legacy-token-streaming",
320 title: "Stream token by token (legacy)",363 title: "Stream token by token (legacy)",
321 to: "/settings/general",364 to: "/settings/general",
365 scope: "project-defaults",
322 searchTerms: ["response output old compatibility"],366 searchTerms: ["response output old compatibility"],
323 },367 },
324 {368 {
...@@ -399,8 +443,9 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -399,8 +443,9 @@ export const SETTINGS_SEARCH_ITEMS = [
399 {443 {
400 id: "agent-browser-access",444 id: "agent-browser-access",
401 title: "Agent browser access",445 title: "Agent browser access",
402 to: "/settings/projects",446 to: "/settings/integrations",
403 searchTerms: ["allow open drive preview tools sessions"],447 scope: "project-defaults",
448 searchTerms: ["allow disable enable open drive preview tools sessions project override"],
404 },449 },
405 {450 {
406 id: "device-hosts",451 id: "device-hosts",
...@@ -476,10 +521,25 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -476,10 +521,25 @@ export const SETTINGS_SEARCH_ITEMS = [
476 to: "/settings/integrations",521 to: "/settings/integrations",
477 searchTerms: ["agent opens browser pop into view hide"],522 searchTerms: ["agent opens browser pop into view hide"],
478 },523 },
524 {
525 id: "automatic-pull",
526 title: "Automatically pull",
527 to: "/settings/source-control",
528 scope: "project-defaults",
529 searchTerms: ["auto pull default branch current checkout fast forward upstream"],
530 },
531 {
532 id: "pull-request-merge-method",
533 title: "Default merge method",
534 to: "/settings/source-control",
535 scope: "project-defaults",
536 searchTerms: ["pull request merge squash rebase last selected"],
537 },
479 {538 {
480 id: "source-control",539 id: "source-control",
481 title: "Source control",540 title: "Source control",
482 to: "/settings/source-control",541 to: "/settings/source-control",
542 scope: "environment-defaults",
483 searchTerms: [543 searchTerms: [
484 "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",544 "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
485 ],545 ],
...@@ -491,7 +551,8 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -491,7 +551,8 @@ export const SETTINGS_SEARCH_ITEMS = [
491 searchTerms: [551 searchTerms: [
492 "automatic remote branch refresh background credentials security keys seconds off",552 "automatic remote branch refresh background credentials security keys seconds off",
493 ],553 ],
494 primaryOnly: true,554 environmentOnly: true,
555 scope: "environment-defaults",
495 },556 },
496 {557 {
497 id: "source-control-writing-style",558 id: "source-control-writing-style",
...@@ -500,14 +561,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -500,14 +561,14 @@ export const SETTINGS_SEARCH_ITEMS = [
500 searchTerms: [561 searchTerms: [
501 "repository conventions conventional commits custom instructions change descriptions request titles",562 "repository conventions conventional commits custom instructions change descriptions request titles",
502 ],563 ],
503 primaryOnly: true,564 environmentOnly: true,
504 },565 },
505 {566 {
506 id: "follow-change-request-templates",567 id: "follow-change-request-templates",
507 title: "Follow change request templates",568 title: "Follow change request templates",
508 to: "/settings/source-control",569 to: "/settings/source-control",
509 searchTerms: ["repository pr pull request description structure"],570 searchTerms: ["repository pr pull request description structure"],
510 primaryOnly: true,571 environmentOnly: true,
511 },572 },
512 {573 {
513 id: "source-control-writer-model",574 id: "source-control-writer-model",
...@@ -516,7 +577,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -516,7 +577,14 @@ export const SETTINGS_SEARCH_ITEMS = [
516 searchTerms: [577 searchTerms: [
517 "override generated commit change request pr titles descriptions branch bookmark",578 "override generated commit change request pr titles descriptions branch bookmark",
518 ],579 ],
519 primaryOnly: true,580 environmentOnly: true,
581 scope: "project-defaults",
582 },
583 {
584 id: "project-actions",
585 title: "Actions",
586 to: "/settings/projects",
587 searchTerms: ["commands scripts setup run dev server checkout worktree t3.json import"],
520 },588 },
521 {589 {
522 id: "environment-icon",590 id: "environment-icon",
...@@ -606,6 +674,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];...@@ -606,6 +674,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
606674
607const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));675const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
608676
677const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = {
678 "/settings/projects": "project",
679 "/settings/general": null,
680 "/settings/appearance": null,
681 "/settings/snap-shot": null,
682 // Keybindings fan out to the selection; Providers shows the representative
683 // environment at any selection. Neither needs a particular scope to render.
684 "/settings/keybindings": null,
685 "/settings/providers": null,
686 "/settings/integrations": null,
687 "/settings/source-control": "environment-defaults",
688 "/settings/connections": "connections",
689 "/settings/archived": "project-defaults",
690};
691
692/** Search keeps the selected target. A missing row can explain its owning scope instead. */
693export function getSettingsSearchTargetScope(targetId: string) {
694 const items: readonly SettingsSearchItem[] = SETTINGS_SEARCH_ITEMS;
695 const item =
696 items.find((candidate) => candidate.id === targetId) ??
697 items.find((candidate) => candidate.targetId === targetId);
698 return item
699 ? {
700 title: item.title,
701 scope: item.scope ?? SETTINGS_CATEGORY_SCOPES[item.to],
702 ...(item.requiresThreadAutoSettlement ? { requiresThreadAutoSettlement: true } : {}),
703 }
704 : null;
705}
706
707interface AutoSettlementSearchEnvironment {
708 readonly environmentId: EnvironmentId;
709 readonly connection: { readonly phase: EnvironmentConnectionPhase };
710 readonly serverConfig: {
711 readonly environment: {
712 readonly capabilities: { readonly threadAutoSettlement?: boolean };
713 };
714 } | null;
715}
716
717/** Discovery needs one capable environment; the selected page needs every connected target to support it. */
718export function getThreadAutoSettlementSearchAvailability(
719 environments: readonly AutoSettlementSearchEnvironment[],
720 scope?: Pick<ResolvedSettingsScope, "kind" | "environmentIds">,
721) {
722 const connected = environments.filter(
723 (environment) =>
724 environment.connection.phase === "connected" && environment.serverConfig !== null,
725 );
726 const eligibleEnvironmentIds = connected
727 .filter(
728 (environment) =>
729 environment.serverConfig?.environment.capabilities.threadAutoSettlement === true,
730 )
731 .map((environment) => environment.environmentId);
732 const selected = connected.filter((environment) =>
733 scope?.environmentIds.includes(environment.environmentId),
734 );
735 return {
736 eligibleEnvironmentIds,
737 isTargetAvailable:
738 scope !== undefined &&
739 scope.kind !== "unavailable" &&
740 selected.length > 0 &&
741 selected.every((environment) => eligibleEnvironmentIds.includes(environment.environmentId)),
742 };
743}
744
745export function isSettingsSearchScopeAvailable(
746 requiredScope: SettingsSearchScope | null,
747 scopeKind: ResolvedSettingsScope["kind"],
748): boolean {
749 switch (requiredScope) {
750 case null:
751 case "connections":
752 return true;
753 case "environment":
754 case "checkout":
755 return requiredScope === scopeKind;
756 case "project":
757 return scopeKind === "project" || scopeKind === "checkout";
758 case "environment-defaults":
759 return scopeKind === "environment" || scopeKind === "all";
760 case "project-defaults":
761 return (
762 scopeKind === "environment" ||
763 scopeKind === "all" ||
764 scopeKind === "project" ||
765 scopeKind === "checkout"
766 );
767 }
768}
769
770function settingsScopeKindFromSearch(search: SettingsScopeSearch): ResolvedSettingsScope["kind"] {
771 const target = validateSettingsScopeSearch({ ...search });
772 if (target.checkout && !target.project) return "unavailable";
773 if (target.project) return target.checkout ? "checkout" : "project";
774 return target.machine ? "environment" : "all";
775}
776
777export function isSettingsOverviewVisible(search: SettingsScopeSearch): boolean {
778 const kind = settingsScopeKindFromSearch(search);
779 return kind === "project" || kind === "checkout";
780}
781
609/**782/**
610 * `id` and `title` props for the element a search item anchors to. Panels783 * `id` and `title` props for the element a search item anchors to. Panels
611 * spread (or pick from) this instead of restating the strings, so the catalog784 * spread (or pick from) this instead of restating the strings, so the catalog
...@@ -626,7 +799,7 @@ export function filterAvailableSettingsSearchItems(...@@ -626,7 +799,7 @@ export function filterAvailableSettingsSearchItems(
626 return items.filter(799 return items.filter(
627 (item) =>800 (item) =>
628 (!item.cloudOnly || availability.hasCloudPublicConfig) &&801 (!item.cloudOnly || availability.hasCloudPublicConfig) &&
629 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&802 (!item.environmentOnly || availability.hasEnvironment) &&
630 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&803 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
631 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&804 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
632 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&805 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+8-16
...@@ -1,23 +1,22 @@...@@ -1,23 +1,22 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
3import { AuthAccessWriteScope } from "@t3tools/contracts";2import { AuthAccessWriteScope } from "@t3tools/contracts";
43
5import { hasCloudPublicConfig } from "~/cloud/publicConfig";4import { hasCloudPublicConfig } from "~/cloud/publicConfig";
6import { isElectron } from "~/env";5import { isElectron } from "~/env";
7import { desktopWslStateAtom } from "~/state/desktopWslState";6import { desktopWslStateAtom } from "~/state/desktopWslState";
8import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";7import { useEnvironments } from "~/state/environments";
9import { useEnvironmentQuery } from "~/state/query";8import { useEnvironmentQuery } from "~/state/query";
10import { usePrimarySessionState } from "~/environments/primary";9import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";10import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";11import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
14import { filterAvailableSettingsSearchItems } from "./settingsSearch";12import {
13 filterAvailableSettingsSearchItems,
14 getThreadAutoSettlementSearchAvailability,
15} from "./settingsSearch";
1516
16export function useAvailableSettingsSearchItems() {17export function useAvailableSettingsSearchItems() {
17 const primaryEnvironmentId = usePrimaryEnvironmentId();
18 const { environments } = useEnvironments();18 const { environments } = useEnvironments();
19 const primarySessionState = usePrimarySessionState();19 const primarySessionState = usePrimarySessionState();
20 const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
21 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);20 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
22 const canManageLocalBackend =21 const canManageLocalBackend =
23 isElectron ||22 isElectron ||
...@@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() {...@@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() {
29 () =>28 () =>
30 filterAvailableSettingsSearchItems({29 filterAvailableSettingsSearchItems({
31 hasCloudPublicConfig: hasCloudPublicConfig(),30 hasCloudPublicConfig: hasCloudPublicConfig(),
32 hasPrimaryEnvironment: primaryEnvironmentId !== null,31 hasEnvironment: environments.some((environment) => environment.serverConfig !== null),
33 hasProviderSettingsEnvironment: environments.some((environment) =>32 hasProviderSettingsEnvironment: environments.some((environment) =>
34 isProviderSettingsEnvironmentAvailable({33 isProviderSettingsEnvironmentAvailable({
35 connectionPhase: environment.connection.phase,34 connectionPhase: environment.connection.phase,
...@@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() {...@@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() {
42 error: desktopWsl.error,41 error: desktopWsl.error,
43 }),42 }),
44 hasThreadAutoSettlement:43 hasThreadAutoSettlement:
45 primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,44 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
46 }),45 }),
47 [46 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments],
48 canManageLocalBackend,
49 desktopWsl.data,
50 desktopWsl.error,
51 environments,
52 primaryEnvironmentId,
53 primaryServerConfig,
54 ],
55 );47 );
56}48}
apps/web/src/components/settings/useProjectScriptSettings.ts created+193
...@@ -0,0 +1,193 @@
1import {
2 isAtomCommandInterrupted,
3 mapAtomCommandResult,
4 squashAtomCommandFailure,
5 type AtomCommandResult,
6} from "@t3tools/client-runtime/state/runtime";
7import {
8 type EnvironmentId,
9 type ProjectId,
10 type ProjectScript,
11 type ResolvedKeybindingsConfig,
12 type ServerSettings,
13} from "@t3tools/contracts";
14import { resolveProjectScripts } from "@t3tools/shared/projectScripts";
15import { clearProjectSettingsOverrides } from "@t3tools/shared/projectSettings";
16import * as Cause from "effect/Cause";
17import { AsyncResult } from "effect/unstable/reactivity";
18import { useRef, useState } from "react";
19
20import { isElectron } from "../../env";
21import {
22 decodeProjectScriptKeybindingRule,
23 keybindingValueForCommand,
24} from "../../lib/projectScriptKeybindings";
25import {
26 buildProjectScript,
27 commandForProjectScript,
28 nextProjectScriptId,
29} from "../../projectScripts";
30import { useProjects } from "../../state/entities";
31import { serverEnvironment } from "../../state/server";
32import { useAtomCommand } from "../../state/use-atom-command";
33import type { NewProjectScriptInput } from "../projectScriptEditor";
34import { toastManager } from "../ui/toast";
35
36function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {
37 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
38 const error = squashAtomCommandFailure(result);
39 toastManager.add({
40 type: "error",
41 title: "Failed to save project actions",
42 description: error instanceof Error ? error.message : "An error occurred.",
43 });
44 }
45 return mapAtomCommandResult(result, () => undefined);
46}
47
48/**
49 * Edits the action list on every target: the environment default when there is
50 * no project, else that project's override entry. Shortcuts follow on desktop.
51 */
52export function useProjectScriptSettings(
53 targets: readonly {
54 environmentId: EnvironmentId;
55 settings: ServerSettings;
56 keybindings: ResolvedKeybindingsConfig;
57 project?: { id: ProjectId; scripts: readonly ProjectScript[] };
58 }[],
59) {
60 const projects = useProjects();
61 const [saving, setSaving] = useState(false);
62 const savingRef = useRef(false);
63 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
64 const upsertKeybinding = useAtomCommand(
65 serverEnvironment.upsertKeybinding,
66 "action shortcut update",
67 );
68 const removeKeybinding = useAtomCommand(
69 serverEnvironment.removeKeybinding,
70 "action shortcut removal",
71 );
72
73 async function persist(
74 transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
75 scriptId?: string,
76 keybinding?: string | null,
77 ): Promise<AtomCommandResult<void, unknown>> {
78 if (savingRef.current || targets.length === 0) {
79 const message = "No available machine, or another action change is saving.";
80 toastManager.add({ type: "error", title: "Actions not saved", description: message });
81 return AsyncResult.failure(Cause.fail(new Error(message)));
82 }
83 savingRef.current = true;
84 setSaving(true);
85 try {
86 for (const { environmentId, settings, keybindings, project } of targets) {
87 const current = project
88 ? resolveProjectScripts(settings, project)
89 : settings.defaultProjectScripts;
90 const nextScripts = transform(current);
91 const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
92 const result = await updateSettings({
93 environmentId,
94 input: {
95 patch: project
96 ? {
97 projectSettingsOverrides: {
98 [project.id]:
99 nextScripts === null
100 ? clearProjectSettingsOverrides(settings, project.id, [
101 "defaultProjectScripts",
102 ])
103 : {
104 ...settings.projectSettingsOverrides[project.id],
105 defaultProjectScripts: nextScripts,
106 },
107 },
108 }
109 : { defaultProjectScripts: nextScripts ?? [] },
110 },
111 });
112 if (result._tag === "Failure") return reportScriptFailure(result);
113 if (!isElectron) continue;
114 const changedIds = scriptId
115 ? [scriptId]
116 : current
117 .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
118 .map((script) => script.id);
119 for (const id of changedIds) {
120 const command = commandForProjectScript(id);
121 const previousValue = keybindingValueForCommand(keybindings, command);
122 const previous = previousValue
123 ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
124 : null;
125 const next = decodeProjectScriptKeybindingRule({ keybinding, command });
126 const retainedElsewhere =
127 !nextScripts?.some((script) => script.id === id) &&
128 ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
129 Object.entries(settings.projectSettingsOverrides).some(
130 ([projectId, entry]) =>
131 projectId !== project?.id &&
132 entry.defaultProjectScripts?.some((script) => script.id === id),
133 ) ||
134 projects.some(
135 (other) =>
136 other.environmentId === environmentId &&
137 other.id !== project?.id &&
138 (project ? resolveProjectScripts(settings, other) : other.scripts).some(
139 (script) => script.id === id,
140 ),
141 ));
142 const bindingResult = next
143 ? await upsertKeybinding({
144 environmentId,
145 input:
146 previous && previous.key !== next.key ? { ...next, replace: previous } : next,
147 })
148 : previous && !retainedElsewhere
149 ? await removeKeybinding({ environmentId, input: previous })
150 : null;
151 if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
152 }
153 }
154 return AsyncResult.success(undefined);
155 } finally {
156 savingRef.current = false;
157 setSaving(false);
158 }
159 }
160
161 function submit(scriptId: string | null, input: NewProjectScriptInput) {
162 const existingIds = [
163 ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
164 ...targets.flatMap(({ settings, project }) =>
165 [
166 ...settings.defaultProjectScripts,
167 ...Object.values(settings.projectSettingsOverrides).flatMap(
168 (entry) => entry.defaultProjectScripts ?? [],
169 ),
170 ...(project?.scripts ?? []),
171 ].map((script) => script.id),
172 ),
173 ];
174 const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
175 const next = buildProjectScript(id, input);
176 return persist(
177 (current) => {
178 const updated = current.map((script) =>
179 script.id === id
180 ? next
181 : input.runOnWorktreeCreate
182 ? { ...script, runOnWorktreeCreate: false }
183 : script,
184 );
185 return scriptId === null ? [...updated, next] : updated;
186 },
187 id,
188 input.keybinding,
189 );
190 }
191
192 return { saving, persist, submit };
193}
apps/web/src/components/settings/useScopedModelAvailability.ts created+55
...@@ -0,0 +1,55 @@
1import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";
2import { useCallback } from "react";
3
4import { getCustomModelOptionsByInstance } from "../../modelSelection";
5import {
6 applyProviderInstanceSettings,
7 deriveProviderInstanceEntries,
8 type ProviderInstanceEntry,
9} from "../../providerInstances";
10import { useEnvironments } from "../../state/environments";
11import { useSettingsScope } from "./SettingsScopeContext";
12
13/**
14 * A model choice fans out to every selected target, so it must exist on all
15 * of them. Returns the reason a (instance, model) pair cannot be applied, or
16 * null when every target can honor it. The representative's entries decide
17 * which driver the instance id names.
18 */
19export function useScopedModelDisabledReason(
20 settings: UnifiedSettings,
21 entries: readonly ProviderInstanceEntry[],
22) {
23 const { targets } = useSettingsScope();
24 const { environments } = useEnvironments();
25 return useCallback(
26 (instanceId: ProviderInstanceId, model: string): string | null => {
27 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
28 for (const candidate of targets) {
29 const environment = environments.find(
30 (entry) => entry.environmentId === candidate.environmentId,
31 );
32 const config = environment?.serverConfig;
33 if (!config) continue;
34 const entry = applyProviderInstanceSettings(
35 deriveProviderInstanceEntries(config.providers),
36 candidate.settings,
37 ).find((option) => option.instanceId === instanceId);
38 const options = getCustomModelOptionsByInstance(
39 { ...settings, ...candidate.settings },
40 config.providers,
41 ).get(instanceId);
42 if (
43 !entry?.enabled ||
44 !entry.isAvailable ||
45 entry.driverKind !== sourceEntry?.driverKind ||
46 !options?.some((option) => option.slug === model && !option.isUnavailable)
47 ) {
48 return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`;
49 }
50 }
51 return null;
52 },
53 [entries, environments, settings, targets],
54 );
55}
apps/web/src/components/settings/useScopedSettings.ts created+121
...@@ -0,0 +1,121 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 type ProjectScopedServerSettingKey,
4 type ServerSettings,
5 type UnifiedSettings,
6} from "@t3tools/contracts";
7import { useCallback, useMemo } from "react";
8
9import {
10 mergeEnvironmentSettings,
11 persistClientSettingsPatch,
12 useClientSettings,
13} from "../../hooks/useSettings";
14import { serverEnvironment } from "../../state/server";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { toastManager } from "../ui/toast";
17import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext";
18import {
19 persistScopedSettingsPatch,
20 planProjectOverridesClear,
21 planScopedSettingsClear,
22 planScopedSettingsPatch,
23 scopedSettingsAreMixed,
24 scopedSettingsSource,
25 type ProjectOverrideEntry,
26 type ScopedSettingsPatch,
27} from "./scopedSettings";
28
29/** Effective settings for the representative target: project overrides applied on top of its environment. */
30export function useScopedSettings<T = UnifiedSettings>(
31 selector?: (settings: UnifiedSettings) => T,
32): T {
33 const { target } = useSettingsScope();
34 const clientSettings = useClientSettings();
35 const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;
36 const settings = useMemo(
37 () => mergeEnvironmentSettings(serverSettings, clientSettings),
38 [clientSettings, serverSettings],
39 );
40 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
41}
42
43export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean {
44 const { targets } = useSettingsScope();
45 return scopedSettingsAreMixed(targets, keys);
46}
47
48/** Where the keys' effective values come from across the selected targets. */
49export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) {
50 const { targets } = useSettingsScope();
51 return scopedSettingsSource(targets, keys);
52}
53
54function useRunScopedPlan() {
55 const persistServer = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
56 return useCallback(
57 (plan: ReturnType<typeof planScopedSettingsPatch>) => {
58 if (plan.unavailableReason) {
59 toastManager.add({
60 type: "warning",
61 title: "Setting not saved",
62 description: plan.unavailableReason,
63 });
64 return;
65 }
66 void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then(
67 ({ failedEnvironments, savedEnvironmentCount }) => {
68 if (failedEnvironments.length === 0) return;
69 toastManager.add({
70 type: "error",
71 title:
72 savedEnvironmentCount > 0
73 ? "Setting saved on some environments"
74 : "Setting not saved",
75 description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,
76 });
77 },
78 );
79 },
80 [persistServer],
81 );
82}
83
84export function useUpdateScopedSettings() {
85 const { scope, environments } = useSettingsScope();
86 const run = useRunScopedPlan();
87 return useCallback(
88 (patch: ScopedSettingsPatch) => run(planScopedSettingsPatch(scope, environments, patch)),
89 [environments, run, scope],
90 );
91}
92
93/**
94 * Drop the project overrides for `keys` so the selected checkouts inherit
95 * again. Rows also render outside the settings layout (provider cards,
96 * dialogs), where there is no scope and nothing to clear.
97 */
98export function useClearScopedSettings() {
99 const context = useOptionalSettingsScope();
100 const run = useRunScopedPlan();
101 return useCallback(
102 (keys: readonly ProjectScopedServerSettingKey[]) => {
103 if (context === null) return;
104 run(planScopedSettingsClear(context.scope, context.environments, keys));
105 },
106 [context, run],
107 );
108}
109
110/** Clear `keys` on specific project entries, from an environment scope's chain popover. */
111export function useClearProjectOverrides() {
112 const context = useOptionalSettingsScope();
113 const run = useRunScopedPlan();
114 return useCallback(
115 (entries: readonly ProjectOverrideEntry[], keys: readonly ProjectScopedServerSettingKey[]) => {
116 if (context === null) return;
117 run(planProjectOverridesClear(context.environments, entries, keys));
118 },
119 [context, run],
120 );
121}
apps/web/src/components/ui/button.tsx+2
...@@ -33,6 +33,8 @@ const buttonVariants = cva(...@@ -33,6 +33,8 @@ const buttonVariants = cva(
33 micro:33 micro:
34 "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3",34 "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3",
35 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",35 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
36 "sm-multiline":
37 "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7",
36 xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",38 xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
37 xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",39 xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
38 },40 },
apps/web/src/components/ui/switch.tsx+12-2
...@@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";...@@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
44
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
66
7/**
8 * `mixed` renders the thumb centred on a muted track for a selection whose
9 * targets disagree (the macOS mixed-state convention). It is presentational:
10 * the caller still decides what a click sets, usually on for everyone.
11 */
7function Switch({12function Switch({
8 className,13 className,
9 size = "default",14 size = "default",
15 mixed = false,
10 ...props16 ...props
11}: SwitchPrimitive.Root.Props & { size?: "default" | "sm" }) {17}: SwitchPrimitive.Root.Props & { size?: "default" | "sm"; mixed?: boolean }) {
12 return (18 return (
13 <SwitchPrimitive.Root19 <SwitchPrimitive.Root
14 className={cn(20 className={cn(
15 "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center rounded-full p-[2px] outline-none transition-[background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-64",21 "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center rounded-full p-[2px] outline-none transition-[background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-64 data-[mixed]:bg-input",
16 size === "sm"22 size === "sm"
17 ? "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]"23 ? "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]"
18 : "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]",24 : "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]",
...@@ -20,11 +26,15 @@ function Switch({...@@ -20,11 +26,15 @@ function Switch({
20 )}26 )}
21 data-size={size}27 data-size={size}
22 data-slot="switch"28 data-slot="switch"
29 data-mixed={mixed ? "" : undefined}
30 aria-checked={mixed ? "mixed" : undefined}
23 {...props}31 {...props}
24 >32 >
25 <SwitchPrimitive.Thumb33 <SwitchPrimitive.Thumb
26 className={cn(34 className={cn(
27 "pointer-events-none block size-[calc(var(--thumb-size)-2px)] shrink-0 origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-right data-checked:translate-x-[calc(var(--thumb-size)-4px)]",35 "pointer-events-none block size-[calc(var(--thumb-size)-2px)] shrink-0 origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-right data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
36 mixed &&
37 "translate-x-[calc((var(--thumb-size)-4px)/2)] opacity-70 data-checked:translate-x-[calc((var(--thumb-size)-4px)/2)]",
28 )}38 )}
29 data-slot="switch-thumb"39 data-slot="switch-thumb"
30 />40 />
apps/web/src/hooks/useHandleNewThread.test.ts+104-21
...@@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vite-plus/test";...@@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vite-plus/test";
3const testState = vi.hoisted(() => {3const testState = vi.hoisted(() => {
4 let completeProjectFileRead: (value: null) => void = () => undefined;4 let completeProjectFileRead: (value: null) => void = () => undefined;
5 let projectFileRead = Promise.resolve<null>(null);5 let projectFileRead = Promise.resolve<null>(null);
6 let targetSettings = {
7 defaultThreadEnvMode: "local" as "local" | "worktree",
8 newWorktreesStartFromOrigin: false,
9 defaultModelSelection: null,
10 };
6 let storedDraft: {11 let storedDraft: {
7 readonly draftId: string;12 readonly draftId: string;
8 readonly environmentId: string;13 readonly environmentId: string;
...@@ -35,10 +40,25 @@ const testState = vi.hoisted(() => {...@@ -35,10 +40,25 @@ const testState = vi.hoisted(() => {
35 get projectFileRead() {40 get projectFileRead() {
36 return projectFileRead;41 return projectFileRead;
37 },42 },
38 reset(nextStoredDraft: typeof storedDraft) {43 get targetSettings() {
44 return targetSettings;
45 },
46 reset(
47 nextStoredDraft: typeof storedDraft,
48 workspaceDefaults = {
49 envMode: "local" as "local" | "worktree",
50 startFromOrigin: false,
51 },
52 ) {
39 storedDraft = nextStoredDraft;53 storedDraft = nextStoredDraft;
54 targetSettings = {
55 defaultThreadEnvMode: workspaceDefaults.envMode,
56 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
57 defaultModelSelection: null,
58 };
40 router.state.location.href = "/";59 router.state.location.href = "/";
41 router.navigate.mockClear();60 router.navigate.mockClear();
61 draftStore.setDraftThreadContext.mockClear();
42 draftStore.setLogicalProjectDraftThreadId.mockClear();62 draftStore.setLogicalProjectDraftThreadId.mockClear();
43 projectFileRead = new Promise<null>((resolve) => {63 projectFileRead = new Promise<null>((resolve) => {
44 completeProjectFileRead = resolve;64 completeProjectFileRead = resolve;
...@@ -51,18 +71,18 @@ const testState = vi.hoisted(() => {...@@ -51,18 +71,18 @@ const testState = vi.hoisted(() => {
51vi.mock("@effect/atom-react", () => ({71vi.mock("@effect/atom-react", () => ({
52 useAtomValue: (atom: unknown) =>72 useAtomValue: (atom: unknown) =>
53 atom === "primary-settings"73 atom === "primary-settings"
54 ? { newWorktreesStartFromOrigin: false }74 ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin }
55 : new Map([75 : new Map([
56 [76 [
57 "environment-ssh",77 "environment-primary",
58 {78 {
59 settings: {79 settings: {
60 defaultThreadEnvMode: "local",80 ...testState.targetSettings,
61 newWorktreesStartFromOrigin: false,81 newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin,
62 defaultModelSelection: null,
63 },82 },
64 },83 },
65 ],84 ],
85 ["environment-ssh", { settings: testState.targetSettings }],
66 ]),86 ]),
67}));87}));
68vi.mock("@t3tools/client-runtime/environment", () => ({88vi.mock("@t3tools/client-runtime/environment", () => ({
...@@ -74,6 +94,15 @@ vi.mock("@t3tools/contracts", () => ({...@@ -74,6 +94,15 @@ vi.mock("@t3tools/contracts", () => ({
74 DEFAULT_RUNTIME_MODE: "default",94 DEFAULT_RUNTIME_MODE: "default",
75 DEFAULT_SERVER_SETTINGS: {},95 DEFAULT_SERVER_SETTINGS: {},
76}));96}));
97vi.mock("@t3tools/shared/projectSettings", () => ({
98 // Environment settings pass through; the tests set project fields on the
99 // project record, which the hook still honors until the server folds them.
100 resolveProjectSettings: (settings: Record<string, unknown>) => ({
101 settings,
102 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
103 overrides: {},
104 }),
105}));
77vi.mock("@t3tools/shared/threadEnvMode", () => ({106vi.mock("@t3tools/shared/threadEnvMode", () => ({
78 resolveDefaultThreadEnvMode: (input: {107 resolveDefaultThreadEnvMode: (input: {
79 readonly projectFile: "local" | "worktree" | null;108 readonly projectFile: "local" | "worktree" | null;
...@@ -99,9 +128,9 @@ vi.mock("../composerDraftStore", () => {...@@ -99,9 +128,9 @@ vi.mock("../composerDraftStore", () => {
99 useComposerDraftStore,128 useComposerDraftStore,
100 };129 };
101});130});
102vi.mock("../lib/chatThreadActions", () => ({131vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
132 ...(await importOriginal<typeof import("../lib/chatThreadActions")>()),
103 hasExplicitComposerModelSelection: () => false,133 hasExplicitComposerModelSelection: () => false,
104 resolveNewDraftStartFromOrigin: () => false,
105 resolveNewThreadModelSelectionOverride: () => null,134 resolveNewThreadModelSelectionOverride: () => null,
106}));135}));
107vi.mock("../lib/t3ProjectFileDefaults", () => ({136vi.mock("../lib/t3ProjectFileDefaults", () => ({
...@@ -143,19 +172,19 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));...@@ -143,19 +172,19 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));
143172
144import { useNewThreadHandler } from "./useHandleNewThread";173import { useNewThreadHandler } from "./useHandleNewThread";
145174
146describe("useNewThreadHandler", () => {175describe.each([
147 it.each([176 ["new", null],
148 ["new", null],177 [
149 [178 "reusable",
150 "reusable",179 {
151 {180 draftId: "draft-existing",
152 draftId: "draft-existing",181 environmentId: "environment-ssh",
153 environmentId: "environment-ssh",182 promotedTo: null,
154 promotedTo: null,183 threadId: "thread-existing",
155 threadId: "thread-existing",184 },
156 },185 ],
157 ],186])("useNewThreadHandler with a %s draft", (_, draft) => {
158 ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => {187 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
159 testState.reset(draft);188 testState.reset(draft);
160 const openThread = useNewThreadHandler();189 const openThread = useNewThreadHandler();
161 const pendingOpen = openThread(190 const pendingOpen = openThread(
...@@ -171,4 +200,58 @@ describe("useNewThreadHandler", () => {...@@ -171,4 +200,58 @@ describe("useNewThreadHandler", () => {
171 expect(testState.router.navigate).not.toHaveBeenCalled();200 expect(testState.router.navigate).not.toHaveBeenCalled();
172 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();201 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
173 });202 });
203
204 it.each([true, false])(
205 "uses the target environment's start-from-origin default of %s",
206 async (startFromOrigin) => {
207 testState.reset(draft, { envMode: "worktree", startFromOrigin });
208 const openThread = useNewThreadHandler();
209 const projectRef = {
210 environmentId: "environment-ssh",
211 projectId: "project-remote",
212 } as never;
213 const pendingOpen = openThread(projectRef);
214
215 testState.completeProjectFileRead(null);
216 const opened = await pendingOpen;
217
218 expect(opened).toEqual({
219 draftId: draft?.draftId ?? "draft-delayed",
220 threadId: draft?.threadId ?? "thread-delayed",
221 });
222 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
223 "remote-project",
224 projectRef,
225 opened!.draftId,
226 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
227 );
228 if (draft) {
229 expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith(
230 draft.draftId,
231 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
232 );
233 }
234 },
235 );
236
237 it.each([true, false])(
238 "preserves an explicit start-from-origin choice of %s",
239 async (startFromOrigin) => {
240 testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin });
241 const openThread = useNewThreadHandler();
242 const projectRef = {
243 environmentId: "environment-ssh",
244 projectId: "project-remote",
245 } as never;
246
247 const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin });
248
249 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
250 "remote-project",
251 projectRef,
252 opened!.draftId,
253 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
254 );
255 },
256 );
174});257});
apps/web/src/hooks/useHandleNewThread.ts+21-16
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 getProjectOrderKey,27 getProjectOrderKey,
28 selectProjectGroupingSettings,28 selectProjectGroupingSettings,
29} from "../logicalProject";29} from "../logicalProject";
30import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
30import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";31import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
31import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";32import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
32import {33import {
...@@ -35,7 +36,7 @@ import {...@@ -35,7 +36,7 @@ import {
35 resolveNewThreadModelSelectionOverride,36 resolveNewThreadModelSelectionOverride,
36} from "../lib/chatThreadActions";37} from "../lib/chatThreadActions";
37import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";38import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
38import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";39import { environmentServerConfigsAtom } from "../state/server";
39import { resolveThreadRouteTarget } from "../threadRoutes";40import { resolveThreadRouteTarget } from "../threadRoutes";
40import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";41import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
41import { useClientSettings } from "./useSettings";42import { useClientSettings } from "./useSettings";
...@@ -61,7 +62,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef...@@ -61,7 +62,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
6162
62export function useNewThreadHandler() {63export function useNewThreadHandler() {
63 const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);64 const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
64 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
65 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);65 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
66 const router = useRouter();66 const router = useRouter();
67 const getCurrentRouteTarget = useCallback(() => {67 const getCurrentRouteTarget = useCallback(() => {
...@@ -139,10 +139,21 @@ export function useNewThreadHandler() {...@@ -139,10 +139,21 @@ export function useNewThreadHandler() {
139 candidate.id === projectRef.projectId &&139 candidate.id === projectRef.projectId &&
140 candidate.environmentId === projectRef.environmentId,140 candidate.environmentId === projectRef.environmentId,
141 );141 );
142 // The resolver applies project overrides and, until the server has
143 // folded them, the aggregate's own legacy fields.
144 const projectSettings = resolveProjectSettings(
145 targetServerSettings,
146 project?.id ?? null,
147 project,
148 );
149 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
150 const projectThreadEnvMode =
151 projectSettings.sources.defaultThreadEnvMode === "project"
152 ? projectSettings.settings.defaultThreadEnvMode
153 : undefined;
142 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>154 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
143 resolveNewThreadModelSelectionOverride({155 resolveNewThreadModelSelectionOverride({
144 projectDefaultSelection:156 projectDefaultSelection: projectDefaultModelSelection ?? null,
145 project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
146 carrySelection: carryModelSelection,157 carrySelection: carryModelSelection,
147 carrySourceDraftId:158 carrySourceDraftId:
148 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,159 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
...@@ -152,16 +163,16 @@ export function useNewThreadHandler() {...@@ -152,16 +163,16 @@ export function useNewThreadHandler() {
152 // skipped entirely when a higher-priority source decides, and its163 // skipped entirely when a higher-priority source decides, and its
153 // query atom caches per project after the first call.164 // query atom caches per project after the first call.
154 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {165 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
155 const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null;166 const consultProjectFile = project !== undefined && projectThreadEnvMode == null;
156 return resolveDefaultThreadEnvMode({167 return resolveDefaultThreadEnvMode({
157 projectSetting: project?.defaultThreadEnvMode,168 projectSetting: projectThreadEnvMode,
158 projectFile: consultProjectFile169 projectFile: consultProjectFile
159 ? await readT3ProjectFileDefaultThreadEnvMode(170 ? await readT3ProjectFileDefaultThreadEnvMode(
160 project.environmentId,171 project.environmentId,
161 project.workspaceRoot,172 project.workspaceRoot,
162 )173 )
163 : null,174 : null,
164 globalDefault: targetServerSettings.defaultThreadEnvMode,175 globalDefault: projectSettings.settings.defaultThreadEnvMode,
165 });176 });
166 };177 };
167 const logicalProjectKey = project178 const logicalProjectKey = project
...@@ -258,7 +269,7 @@ export function useNewThreadHandler() {...@@ -258,7 +269,7 @@ export function useNewThreadHandler() {
258 envMode: defaultEnvMode,269 envMode: defaultEnvMode,
259 startFromOrigin: resolveNewDraftStartFromOrigin({270 startFromOrigin: resolveNewDraftStartFromOrigin({
260 envMode: defaultEnvMode,271 envMode: defaultEnvMode,
261 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,272 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
262 }),273 }),
263 };274 };
264 }275 }
...@@ -413,7 +424,7 @@ export function useNewThreadHandler() {...@@ -413,7 +424,7 @@ export function useNewThreadHandler() {
413 options?.startFromOrigin ??424 options?.startFromOrigin ??
414 resolveNewDraftStartFromOrigin({425 resolveNewDraftStartFromOrigin({
415 envMode: initialEnvMode,426 envMode: initialEnvMode,
416 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,427 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
417 }),428 }),
418 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,429 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
419 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),430 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
...@@ -433,13 +444,7 @@ export function useNewThreadHandler() {...@@ -433,13 +444,7 @@ export function useNewThreadHandler() {
433 return { draftId, threadId };444 return { draftId, threadId };
434 })();445 })();
435 },446 },
436 [447 [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router],
437 environmentServerConfigs,
438 getCurrentRouteTarget,
439 primaryServerSettings.newWorktreesStartFromOrigin,
440 projectGroupingSettings,
441 router,
442 ],
443 );448 );
444}449}
445450
apps/web/src/hooks/useSettings.ts-69
...@@ -27,8 +27,6 @@ import {...@@ -27,8 +27,6 @@ import {
27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";27import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
28import {28import {
29 filterSharedServerPatch,29 filterSharedServerPatch,
30 findSharedSettingsMismatches,
31 pickSharedServerSettings,
32 splitSharedServerPatch,30 splitSharedServerPatch,
33 supportsSharedSettingsSync,31 supportsSharedSettingsSync,
34} from "@t3tools/client-runtime/state/shared-settings";32} from "@t3tools/client-runtime/state/shared-settings";
...@@ -493,73 +491,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -493,73 +491,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
493 return updateSettings;491 return updateSettings;
494}492}
495493
496/**
497 * Shared-settings sync targets whose values differ from the primary's,
498 * plus an action that writes the primary's values to all of them. Drift
499 * happens when an environment was offline during an edit or was changed by
500 * an older client.
501 */
502export function useSharedSettingsSync() {
503 const primaryEnvironment = usePrimaryEnvironment();
504 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
505 const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities;
506 // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls
507 // back to defaults while the primary is disconnected, and "apply to all"
508 // must never push defaults over real values. Same for a primary too old to
509 // hold the shared keys: its decoded defaults are not a source of truth.
510 const primarySettings =
511 primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment)
512 ? (primaryEnvironment.serverConfig?.settings ?? null)
513 : null;
514 const { environments } = useEnvironments();
515 const persistServerSettings = useAtomCommand(
516 serverEnvironment.updateSettings,
517 "server settings update",
518 );
519
520 const mismatches = useMemo(
521 () =>
522 findSharedSettingsMismatches({
523 primaryEnvironmentId,
524 primarySettings,
525 primaryCapabilities,
526 environments: environments.map((environment) => ({
527 environmentId: environment.environmentId,
528 label: environment.label,
529 syncEligible: supportsSharedSettingsSync(environment),
530 settings: environment.serverConfig?.settings ?? null,
531 capabilities: environment.serverConfig?.environment.capabilities,
532 })),
533 }),
534 [environments, primaryEnvironmentId, primarySettings, primaryCapabilities],
535 );
536
537 const applyToAll = useCallback(() => {
538 if (primarySettings === null) {
539 return;
540 }
541 const patch = pickSharedServerSettings(primarySettings, primaryCapabilities);
542 for (const mismatch of mismatches) {
543 const target = environments.find(
544 (candidate) => candidate.environmentId === mismatch.environmentId,
545 );
546 void persistServerSettings({
547 environmentId: mismatch.environmentId,
548 input: {
549 patch: filterSharedServerPatch(
550 patch,
551 target?.serverConfig?.environment.capabilities,
552 target?.serverConfig?.settings,
553 primarySettings,
554 ),
555 },
556 });
557 }
558 }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]);
559
560 return { mismatches, applyToAll };
561}
562
563export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {494export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
564 return useUpdateSettingsTarget(environmentId);495 return useUpdateSettingsTarget(environmentId);
565}496}
apps/web/src/lib/resourceTelemetryState.ts+20-5
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 ResourceTelemetryHistoryInput,
4 ResourceTelemetrySnapshot,
5} from "@t3tools/contracts";
2import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
3import { useCallback } from "react";7import { useCallback } from "react";
48
...@@ -15,9 +19,14 @@ export interface ResourceTelemetryState {...@@ -15,9 +19,14 @@ export interface ResourceTelemetryState {
15 readonly retry: () => Promise<ResourceTelemetrySnapshot>;19 readonly retry: () => Promise<ResourceTelemetrySnapshot>;
16}20}
1721
18export function useResourceTelemetry(): ResourceTelemetryState {22export function useResourceTelemetry(
23 targetEnvironmentId?: EnvironmentId | null,
24): ResourceTelemetryState {
19 const primaryEnvironment = usePrimaryEnvironment();25 const primaryEnvironment = usePrimaryEnvironment();
20 const environmentId = primaryEnvironment?.environmentId ?? null;26 const environmentId =
27 targetEnvironmentId === undefined
28 ? (primaryEnvironment?.environmentId ?? null)
29 : targetEnvironmentId;
21 const query = useEnvironmentQuery(30 const query = useEnvironmentQuery(
22 environmentId === null31 environmentId === null
23 ? null32 ? null
...@@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState {...@@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState {
40 return { ...query, retry };49 return { ...query, retry };
41}50}
4251
43export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) {52export function useResourceTelemetryHistory(
53 input: ResourceTelemetryHistoryInput,
54 targetEnvironmentId?: EnvironmentId | null,
55) {
44 const primaryEnvironment = usePrimaryEnvironment();56 const primaryEnvironment = usePrimaryEnvironment();
45 const environmentId = primaryEnvironment?.environmentId ?? null;57 const environmentId =
58 targetEnvironmentId === undefined
59 ? (primaryEnvironment?.environmentId ?? null)
60 : targetEnvironmentId;
46 return useEnvironmentQuery(61 return useEnvironmentQuery(
47 environmentId === null62 environmentId === null
48 ? null63 ? null
apps/web/src/routes/settings.integrations.tsx+1-5
...@@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";...@@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";
22
3import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings";3import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings";
44
5function SettingsIntegrationsRoute() {
6 return <IntegrationsSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/integrations")({5export const Route = createFileRoute("/settings/integrations")({
10 component: SettingsIntegrationsRoute,6 component: IntegrationsSettingsPanel,
11});7});
apps/web/src/routes/settings.projects.tsx+1-19
...@@ -1,24 +1,6 @@...@@ -1,24 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { ProjectsSettings } from "../components/settings/ProjectsSettings";2import { ProjectsSettings } from "../components/settings/ProjectsSettings";
3import { validateSettingsScopeSearch } from "../components/settings/settingsScope";
43
5export const Route = createFileRoute("/settings/projects")({4export const Route = createFileRoute("/settings/projects")({
6 validateSearch: validateSettingsScopeSearch,5 component: ProjectsSettings,
7 component: ProjectsRoute,
8});6});
9
10function ProjectsRoute() {
11 const search = Route.useSearch();
12 const navigate = Route.useNavigate();
13 return (
14 <ProjectsSettings
15 value={search}
16 onScopeChange={(scope) => {
17 void navigate({
18 search: scope,
19 replace: true,
20 });
21 }}
22 />
23 );
24}
apps/web/src/routes/settings.providers.tsx+23-1
...@@ -2,10 +2,32 @@ import { createFileRoute } from "@tanstack/react-router";...@@ -2,10 +2,32 @@ import { createFileRoute } from "@tanstack/react-router";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
33
4import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";4import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
5import { useSettingsScope } from "../components/settings/SettingsScopeContext";
56
7/**
8 * Providers are machine state, so the page shows one environment at a time:
9 * the chosen one, or the representative of the selection. A project crumb
10 * narrows the candidates to the environments that project is registered on.
11 */
6function SettingsProvidersRoute() {12function SettingsProvidersRoute() {
7 const target = Route.useSearch();13 const target = Route.useSearch();
8 return <ProviderSettingsPanel {...target} />;14 const { environment, scope } = useSettingsScope();
15 if (!environment) {
16 return (
17 <p className="p-8 text-sm text-muted-foreground">
18 {scope.kind === "environment"
19 ? `Reconnect ${scope.label} to set up its providers.`
20 : "Connect an environment to set up its providers."}
21 </p>
22 );
23 }
24 return (
25 <ProviderSettingsPanel
26 environmentId={environment.environmentId}
27 {...(target.instanceId ? { instanceId: target.instanceId } : {})}
28 scoped
29 />
30 );
9}31}
1032
11export const Route = createFileRoute("/settings/providers")({33export const Route = createFileRoute("/settings/providers")({
apps/web/src/routes/settings.tsx+139-16
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { RotateCcwIcon } from "lucide-react";
2import {1import {
3 Outlet,2 Outlet,
4 createFileRoute,3 createFileRoute,
...@@ -7,18 +6,34 @@ import {...@@ -7,18 +6,34 @@ import {
7 useLocation,6 useLocation,
8 useNavigate,7 useNavigate,
9} from "@tanstack/react-router";8} from "@tanstack/react-router";
10import { useCallback, useEffect, useState } from "react";9import { useCallback, useEffect, useState, type ReactNode } from "react";
1110import { RotateCcwIcon } from "lucide-react";
11import { Button } from "../components/ui/button";
12import { useSettingsRestore } from "../components/settings/SettingsPanels";12import { useSettingsRestore } from "../components/settings/SettingsPanels";
13
13import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";14import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
14import { Button } from "../components/ui/button";
15import { SidebarInset } from "../components/ui/sidebar";15import { SidebarInset } from "../components/ui/sidebar";
16import { WorkspacePageHeader } from "../components/WorkspacePageHeader";16import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
17import { isElectron } from "../env";17import { isElectron } from "../env";
18import {
19 SettingsScopeProvider,
20 useSettingsScope,
21} from "../components/settings/SettingsScopeContext";
22import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups";
23import { useEnvironments } from "../state/environments";
24import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";
25import {
26 retainSettingsScope,
27 validateSettingsRouteSearch,
28} from "../components/settings/settingsScopeNavigation";
29import {
30 getSettingsSearchTargetScope,
31 getThreadAutoSettlementSearchAvailability,
32 isSettingsSearchScopeAvailable,
33} from "../components/settings/settingsSearch";
1834
19function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {35function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) {
20 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);36 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
21
22 return (37 return (
23 <Button38 <Button
24 size="xs"39 size="xs"
...@@ -27,18 +42,87 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {...@@ -27,18 +42,87 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
27 onClick={() => void restoreDefaults()}42 onClick={() => void restoreDefaults()}
28 >43 >
29 <RotateCcwIcon className="mx-1 size-3.5" />44 <RotateCcwIcon className="mx-1 size-3.5" />
30 Restore defaults45 Restore device defaults
31 </Button>46 </Button>
32 );47 );
33}48}
3449
50/** Pages whose every row is saved on this client; the scope selects are hidden there. */
51const DEVICE_ONLY_PATHS = new Set([
52 "/settings/appearance",
53 "/settings/snap-shot",
54 "/settings/connections",
55]);
56
57function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {
58 const { scope, connectedEnvironments } = useSettingsScope();
59 const { environments } = useEnvironments();
60 const hash = useLocation({ select: (location) => location.hash });
61 const searchTarget = getSettingsSearchTargetScope(hash);
62 const autoSettlementAvailability = searchTarget?.requiresThreadAutoSettlement
63 ? getThreadAutoSettlementSearchAvailability(environments, scope)
64 : null;
65 if (
66 scope.kind !== "unavailable" &&
67 searchTarget &&
68 autoSettlementAvailability &&
69 !autoSettlementAvailability.isTargetAvailable
70 ) {
71 return (
72 <SettingsScopeNotice
73 target="environment"
74 targetId={hash}
75 eligibleEnvironmentIds={autoSettlementAvailability.eligibleEnvironmentIds}
76 >
77 {autoSettlementAvailability.eligibleEnvironmentIds.length > 0
78 ? `${searchTarget.title} requires a supporting environment. Choose one to continue.`
79 : `${searchTarget.title} requires a supporting environment. Connect or update an environment to continue.`}
80 </SettingsScopeNotice>
81 );
82 }
83 if (
84 scope.kind !== "unavailable" &&
85 searchTarget &&
86 !isSettingsSearchScopeAvailable(searchTarget.scope, scope.kind)
87 ) {
88 const target =
89 searchTarget.scope === "environment" ||
90 searchTarget.scope === "project" ||
91 searchTarget.scope === "checkout"
92 ? searchTarget.scope
93 : "all";
94 return (
95 <SettingsScopeNotice target={target} targetId={hash}>
96 {`${searchTarget.title} is not available for the selected target. Choose its owning scope to continue.`}
97 </SettingsScopeNotice>
98 );
99 }
100 // Device-local pages ignore the scope entirely; the project page follows
101 // remembered members while a grouping change replaces its URL key.
102 if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") {
103 return children;
104 }
105 if (scope.kind === "unavailable")
106 return <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>;
107 if (scope.kind === "environment" && connectedEnvironments.length === 0) {
108 return (
109 <p className="p-8 text-sm text-muted-foreground">
110 Reconnect {scope.label} to change its settings.
111 </p>
112 );
113 }
114 return children;
115}
116
35function SettingsContentLayout() {117function SettingsContentLayout() {
36 const location = useLocation();118 const location = useLocation();
37 const navigate = useNavigate();119 const navigate = useNavigate();
38 const canGoBack = useCanGoBack();120 const canGoBack = useCanGoBack();
121 const { search, selectScope } = useSettingsScope();
122 const groups = useSettingsProjectGroups();
123 const { environments } = useEnvironments();
39 const [restoreSignal, setRestoreSignal] = useState(0);124 const [restoreSignal, setRestoreSignal] = useState(0);
40 const showRestoreDefaults = location.pathname === "/settings/general";125 const showScope = !DEVICE_ONLY_PATHS.has(location.pathname);
41 const handleRestored = () => setRestoreSignal((value) => value + 1);
42 const navigateBackWithinApp = useCallback(() => {126 const navigateBackWithinApp = useCallback(() => {
43 if (canGoBack) {127 if (canGoBack) {
44 window.history.back();128 window.history.back();
...@@ -73,17 +157,31 @@ function SettingsContentLayout() {...@@ -73,17 +157,31 @@ function SettingsContentLayout() {
73 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">157 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
74 <WorkspacePageHeader electron={isElectron}>158 <WorkspacePageHeader electron={isElectron}>
75 <div className="flex w-full items-center gap-3">159 <div className="flex w-full items-center gap-3">
76 <SettingsBreadcrumb pathname={location.pathname} />160 <SettingsBreadcrumb
77 {showRestoreDefaults ? (161 pathname={location.pathname}
78 <div className="ms-auto flex items-center gap-2">162 scope={
79 <RestoreDefaultsButton onRestored={handleRestored} />163 showScope
164 ? { value: search, groups, environments, onChange: selectScope }
165 : undefined
166 }
167 />
168 {location.pathname === "/settings/general" ? (
169 <div className="ms-auto flex shrink-0 items-center">
170 <RestoreDeviceDefaultsButton
171 onRestored={() => setRestoreSignal((value) => value + 1)}
172 />
80 </div>173 </div>
81 ) : null}174 ) : null}
82 </div>175 </div>
83 </WorkspacePageHeader>176 </WorkspacePageHeader>
84177
85 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">178 <div
86 <Outlet />179 key={`${JSON.stringify(search)}:${restoreSignal}`}
180 className="min-h-0 flex flex-1 flex-col"
181 >
182 <SettingsScopeBoundary pathname={location.pathname}>
183 <Outlet />
184 </SettingsScopeBoundary>
87 </div>185 </div>
88 </div>186 </div>
89 </SidebarInset>187 </SidebarInset>
...@@ -91,10 +189,35 @@ function SettingsContentLayout() {...@@ -91,10 +189,35 @@ function SettingsContentLayout() {
91}189}
92190
93function SettingsRouteLayout() {191function SettingsRouteLayout() {
94 return <SettingsContentLayout />;192 const rawSearch = Route.useSearch();
193 const navigate = Route.useNavigate();
194 const pathname = useLocation({ select: (location) => location.pathname });
195 return (
196 <SettingsScopeProvider
197 search={rawSearch}
198 onChange={(next) => {
199 // Send every axis so the retain middleware sees an explicit target
200 // even when the choice is "all", which is the absence of a key.
201 void navigate({
202 to: pathname,
203 search: () => ({
204 project: next.project,
205 machine: next.machine,
206 checkout: next.checkout,
207 }),
208 hash: "",
209 resetScroll: false,
210 });
211 }}
212 >
213 <SettingsContentLayout />
214 </SettingsScopeProvider>
215 );
95}216}
96217
97export const Route = createFileRoute("/settings")({218export const Route = createFileRoute("/settings")({
219 validateSearch: validateSettingsRouteSearch,
220 search: { middlewares: [retainSettingsScope] },
98 beforeLoad: async ({ context, location }) => {221 beforeLoad: async ({ context, location }) => {
99 if (222 if (
100 context.authGateState.status !== "authenticated" &&223 context.authGateState.status !== "authenticated" &&
apps/web/src/state/server.ts-9
...@@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make(...@@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make(
100 get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS,100 get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS,
101).pipe(Atom.withLabel("web-primary-server-available-editors"));101).pipe(Atom.withLabel("web-primary-server-available-editors"));
102102
103export const primaryServerKeybindingsConfigPathAtom = Atom.make(
104 (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null,
105).pipe(Atom.withLabel("web-primary-server-keybindings-config-path"));
106
107const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];103const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];
108104
109/**105/**
...@@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make(...@@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make(
115 (get): ReadonlyArray<EnvironmentTheme> =>111 (get): ReadonlyArray<EnvironmentTheme> =>
116 get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,112 get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,
117).pipe(Atom.withLabel("web-primary-server-environment-themes"));113).pipe(Atom.withLabel("web-primary-server-environment-themes"));
118
119export const primaryServerObservabilityAtom = Atom.make(
120 (get): ServerConfig["observability"] | null =>
121 get(primaryServerConfigAtom)?.observability ?? null,
122).pipe(Atom.withLabel("web-primary-server-observability"));
docs/internals/overview.md+9
...@@ -41,6 +41,15 @@ Provider-specific behavior belongs behind an adapter. Orchestration works with n...@@ -41,6 +41,15 @@ Provider-specific behavior belongs behind an adapter. Orchestration works with n
41and events, so adding a provider should not require branches throughout the domain or clients.41and events, so adding a provider should not require branches throughout the domain or clients.
42See [provider constraints](./providers.md).42See [provider constraints](./providers.md).
4343
44## Settings ownership
45
46Client preferences stay in the current client; environment defaults and project overrides stay
47on their owning server. The web and desktop settings target is URL state, resolved against current
48connections and project membership. An unavailable target must not fall back to another environment.
49**All environments** is an explicit bulk edit of connected, loaded servers, not a durable global
50default or a promise to synchronize offline or future environments. Project-group targets similarly
51select known environment-local checkouts; the group itself does not store inherited defaults.
52
44## Durable intent and side effects53## Durable intent and side effects
4554
46The event log is the source of truth for orchestration state. The55The event log is the source of truth for orchestration state. The
docs/user/project-settings.md+42-20
...@@ -1,34 +1,56 @@...@@ -1,34 +1,56 @@
1# Project settings1# Settings and project overrides
22
3Open **Settings → Projects**. The project and machine pickers start at **All projects** and3The Settings breadcrumb ends with the environment and project a change applies to. They start
4**All machines**.4at **All environments** and **All projects** and stay selected as you move between categories or
5search for a setting.
56
6Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values.7Preferences saved on this device, such as appearance, confirmations and browser profiles, always
7Select an individual project to override a default. Reset its row to inherit again. Changing a8show and ignore the selection. Everything else is stored on a server. Choose one environment to
8default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence9edit its settings, or leave **All environments** to edit every connected environment at once.
9over machine defaults when the project has no explicit workspace override.10Offline environments keep their current values; this is a bulk edit, not a synced global default.
1011
11Select a machine to limit edits to it. **All machines** writes defaults to connected machines;12Choose a project to override settings for it on the selected environments. A layers icon beside
12offline machines keep their previous values. Mixed values are indicated when selected machines13each server row's title shows where the value comes from: the built-in default, the environment,
13or checkouts disagree. Browser access changes apply when an agent session next starts.14or a project override. Click it to see that chain on every selected environment. An override can
15be reset to inherit again. Settings that cannot be overridden by a project are shown read-only
16while a project is selected.
1417
15Project grouping has a client-wide default across machines, with individual checkout overrides.18When the selected environments disagree, the control shows **Mixed** in place of a value and the
16Shared actions apply to inheriting projects; editing a project's actions creates an independent list.19layers icon turns amber. Picking a value applies it to every selected environment.
17Reset that list to use shared actions again. Existing project actions are preserved.
1820
19Project names, icons, removal, and importing actions from a checkout remain project-specific.21Changing an environment value never touches a project's own override. When projects override the
20When there are several checkouts, the checkout picker selects which actions and grouping to edit.22setting you are editing, the layers icon counts them and the chain lists each one with its value:
23click a project to jump to it, or **Reset all** to make those projects follow the environment
24again.
25
26Providers and diagnostics are per machine: they show one environment at a time, the primary
27one until you pick another. Every other setting fans out to the selection.
28
29## Defaults and inheritance
30
31General contains the model and workspace for new threads. Integrations controls agent browser
32access. Source Control contains automatic pull, the default pull request merge method and text
33generation. The same rows edit environment defaults or project overrides depending on the
34project crumb.
35
36The Project category, shown while a project is selected, holds the project's name, icon, actions,
37checkouts and removal. Actions belong to a project: editing them creates the project's own list
38on each selected environment, and reset returns to the environment's shared list. A project's
39`t3.json` actions can be imported there.
40
41For workspace mode, a project's `t3.json` preference applies when the project has no override.
42Browser access changes apply when an agent session next starts.
2143
22## Project icons44## Project icons
2345
24Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies46Select the project and open Project to choose an icon, emoji, or image. The choice applies to
25to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to47every checkout in the project group and appears on connected clients. Choose **Automatic** to let
26let T3 Code detect an icon again.48T3 Code detect an icon again.
2749
28## Keep the default branch current50## Keep the default branch current
2951
30Enable **Automatically pull** to keep the default-branch checkout up to date with its configured52In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date
31upstream.53with its configured upstream. Choose an environment to set the default or a project to override it.
3254
33T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files,55T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files,
34or local commits. It skips checkouts on another branch or without an upstream. If a checkout has56or local commits. It skips checkouts on another branch or without an upstream. If a checkout has
docs/user/thread-sidebar.md+6-4
...@@ -86,10 +86,12 @@ settlement, but an old closed or merged pull request does not settle work you...@@ -86,10 +86,12 @@ settlement, but an old closed or merged pull request does not settle work you
86resumed after it closed.86resumed after it closed.
8787
88Change these rules in **Settings → General**. They continue to run when your apps88Change these rules in **Settings → General**. They continue to run when your apps
89are closed. Changes apply to connected environments that support shared settings;89are closed. On web and desktop, choose an environment at the top to change only
90offline environments and older servers keep their previous values. If connected90its rules, or **All environments** to update connected environments together.
91environments disagree, **Apply to all** copies your current settings to those named91Mixed values show where the selected environments disagree. Mobile applies these
92in the warning. Changing a rule does not reopen already settled threads.92rules to connected environments that support shared settings. Offline environments
93and older servers keep their previous values. Changing a rule does not reopen
94already settled threads.
9395
94## Link a pull request96## Link a pull request
9597