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 {
4141 DEFAULT_SERVER_SETTINGS,
4242 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
4343 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
44 type ServerSettingsPatch,
4544} from "@t3tools/contracts";
46import {
47 filterSharedServerPatch,
48 findSharedSettingsMismatches,
49 pickSharedServerSettings,
50 supportsSharedSettingsSync,
51} from "@t3tools/client-runtime/state/shared-settings";
45import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
5246import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
5347import {
5448 type AppUpdateCheckState,
......@@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow";
6155import { SettingsSection } from "./components/SettingsSection";
6256import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
6357import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
58import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
6459
6560type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
6661type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
......@@ -588,10 +583,9 @@ function GeneralSettingsSection() {
588583const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
589584
590585/**
591 * Auto-settlement is a user preference that every server has to hold. Mobile
592 * has no primary environment, so the first eligible sync target provides the
593 * reference value. Edits fan out to every eligible target, and a mismatch row
594 * lets the user push the reference out.
586 * Mobile edits auto-settle defaults across connected, capable environments.
587 * The first target supplies the displayed values. Applying them leaves each
588 * environment's other defaults and overrides intact.
595589 */
596590function AutoSettleSettingsRows() {
597591 const { environments } = useEnvironments();
......@@ -610,24 +604,20 @@ function AutoSettleSettingsRows() {
610604 return null;
611605 }
612606
613 const writeToAll = (patch: ServerSettingsPatch) => {
607 const writeToAll = (patch: Partial<AutoSettleSettings>) => {
614608 for (const environment of syncTargets) {
615609 void updateSettings({ environmentId: environment.environmentId, input: { patch } });
616610 }
617611 };
618612
619 const mismatches = findSharedSettingsMismatches({
620 primaryEnvironmentId: reference.environmentId,
621 primarySettings: referenceSettings,
622 primaryCapabilities: reference.serverConfig?.environment.capabilities,
623 environments: environments.map((environment) => ({
613 const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync(
614 { environmentId: reference.environmentId, settings: referenceSettings },
615 syncTargets.map((environment) => ({
624616 environmentId: environment.environmentId,
625617 label: environment.label,
626 syncEligible: supportsSharedSettingsSync(environment),
627618 settings: environment.serverConfig?.settings ?? null,
628 capabilities: environment.serverConfig?.environment.capabilities,
629619 })),
630 });
620 );
631621
632622 const afterDays = referenceSettings.sidebarAutoSettleAfterDays;
633623 const commitDays = () => {
......@@ -681,7 +671,7 @@ function AutoSettleSettingsRows() {
681671 {mismatches.length > 0 ? (
682672 <View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
683673 <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>
685675 <Text className="text-sm text-foreground-muted">
686676 {mismatches.map((mismatch) => mismatch.label).join(", ")}
687677 </Text>
......@@ -689,30 +679,18 @@ function AutoSettleSettingsRows() {
689679 <Pressable
690680 accessibilityRole="button"
691681 onPress={() => {
692 const patch = pickSharedServerSettings(
693 referenceSettings,
694 reference.serverConfig?.environment.capabilities,
695 );
696682 for (const mismatch of mismatches) {
697 const target = environments.find(
698 (candidate) => candidate.environmentId === mismatch.environmentId,
699 );
700683 void updateSettings({
701684 environmentId: mismatch.environmentId,
702 input: {
703 patch: filterSharedServerPatch(
704 patch,
705 target?.serverConfig?.environment.capabilities,
706 target?.serverConfig?.settings,
707 referenceSettings,
708 ),
709 },
685 input: { patch: autoSettlePatch },
710686 });
711687 }
712688 }}
713689 className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
714690 >
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>
716694 </Pressable>
717695 </View>
718696 ) : 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 {
1313 CommandId,
1414 DEFAULT_PROVIDER_INTERACTION_MODE,
1515 DEFAULT_RUNTIME_MODE,
16 DEFAULT_SERVER_SETTINGS,
1617 MessageId,
1718 T3_PROJECT_FILE_NAME,
1819 ThreadId,
1920} from "@t3tools/contracts";
21import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2022import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
2123import {
2224 isDefaultThreadEnvModeSettled,
......@@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
428430 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
429431 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
430432 }, [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;
431448 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
432 projectSetting: selectedProject?.defaultThreadEnvMode,
449 projectSetting: projectThreadEnvMode,
433450 projectFile: t3ProjectFileDefaultMode,
434 globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local",
451 globalDefault: projectSettings.settings.defaultThreadEnvMode,
435452 });
436453 // While unsettled the resolved default is provisional. Nothing may write
437454 // it into the draft during that window (the auto-branch effect does), or
438455 // the frozen interim value beats the t3.json default once it loads.
439456 const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({
440457 explicitMode: selectedProjectDraft.workspaceSelection?.mode,
441 projectSetting: selectedProject?.defaultThreadEnvMode,
458 projectSetting: projectThreadEnvMode,
442459 projectFilePending: t3ProjectFileQuery.isPending,
443460 });
444461 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
......@@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
449466 // value keeps tracking the server setting when the config loads late.
450467 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
451468 const startFromOrigin =
452 draftStartFromOrigin ??
453 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
454 true;
469 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
455470 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
456471
457472 // Antigravity keeps unavailable selections so sign-out or a catalog change
......@@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
463478 );
464479 const projectDefaultModelSelection = resolveDefaultableModelSelection(
465480 selectedEnvironmentServerConfig,
466 selectedProject?.defaultModelSelection ??
467 selectedEnvironmentServerConfig?.settings.defaultModelSelection ??
468 null,
481 projectSettings.settings.defaultModelSelection,
469482 );
470483 const storedStickyModelSelection = useStickyComposerModelSelection();
471484 const stickyModelSelection = resolveDefaultableModelSelection(
apps/web/src/components/ChatView.tsx+42-22
......@@ -68,6 +68,7 @@ import {
6868 projectScriptRuntimeEnv,
6969 resolveProjectScripts,
7070} from "@t3tools/shared/projectScripts";
71import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
7172import { truncate } from "@t3tools/shared/String";
7273import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
7374import {
......@@ -304,7 +305,6 @@ import {
304305 environmentServerConfigsAtom,
305306 primaryServerAvailableEditorsAtom,
306307 primaryServerKeybindingsAtom,
307 primaryServerSettingsAtom,
308308 serverEnvironment,
309309} from "../state/server";
310310import { terminalEnvironment } from "../state/terminal";
......@@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) {
15161516 }, [routeKind, routeThreadRef, routeThreadState]);
15171517 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
15181518 const settings = useEnvironmentSettings(environmentId);
1519 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
15201519 const setStickyComposerModelSelection = useComposerDraftStore(
15211520 (store) => store.setStickyModelSelection,
15221521 );
......@@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) {
18021801 ? buildLocalDraftThread(
18031802 threadId,
18041803 draftThread,
1805 fallbackDraftProject?.defaultModelSelection ??
1806 settings.defaultModelSelection ??
1807 NO_PROVIDER_MODEL_SELECTION,
1804 resolveProjectSettings(
1805 settings,
1806 fallbackDraftProject?.id ?? null,
1807 fallbackDraftProject ?? undefined,
1808 ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
18081809 )
18091810 : undefined,
1810 [
1811 draftThread,
1812 fallbackDraftProject?.defaultModelSelection,
1813 settings.defaultModelSelection,
1814 threadId,
1815 ],
1811 [draftThread, fallbackDraftProject, settings, threadId],
18161812 );
18171813 // Promotion is data-driven: the draft route keeps rendering while the
18181814 // server thread (same pre-allocated ref) starts, so live state must not
......@@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) {
20332029 [activeThread?.environmentId, activeThread?.projectId],
20342030 );
20352031 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 );
20362037 const activeProjectScripts = useMemo(
20372038 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
20382039 [activeProject, settings],
20392040 );
2040 const activeProjectDefaultModelSelection =
2041 activeProject?.defaultModelSelection ?? settings.defaultModelSelection;
2041 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
20422042 const handleNewThreadInActiveProject = useCallback(() => {
20432043 startNewThreadForProject(activeProjectRef, handleNewThread);
20442044 }, [activeProjectRef, handleNewThread]);
......@@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) {
39453945 ],
39463946 );
39473947
3948 const supportsProjectSettingsOverrides =
3949 environmentById.get(environmentId)?.serverConfig?.environment.capabilities
3950 .projectSettingsOverrides === true;
39483951 const persistProjectScripts = useCallback(
39493952 async (input: {
39503953 projectId: ProjectId;
......@@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) {
39583961 await updateProjectScriptSettings({
39593962 environmentId,
39603963 input: {
3961 patch: {
3962 projectScriptOverrides: {
3963 [input.projectId]: input.nextScripts,
3964 },
3965 },
3964 // The canonical key on servers that understand it; the legacy
3965 // per-project map is still translated on older ones.
3966 patch: supportsProjectSettingsOverrides
3967 ? {
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 },
39663980 },
39673981 }),
39683982 () => undefined,
......@@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) {
39874001 }
39884002 return updateResult;
39894003 },
3990 [environmentId, updateProjectScriptSettings, upsertKeybinding],
4004 [
4005 environmentId,
4006 settings.projectSettingsOverrides,
4007 supportsProjectSettingsOverrides,
4008 updateProjectScriptSettings,
4009 upsertKeybinding,
4010 ],
39914011 );
39924012 const saveProjectScript = useCallback(
39934013 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
......@@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) {
53965416 ? (draftThread?.startFromOrigin ?? false)
53975417 : canOverrideServerThreadEnvMode
53985418 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
5399 primaryServerSettings.newWorktreesStartFromOrigin)
5419 activeProjectSettings.settings.newWorktreesStartFromOrigin)
54005420 : false;
54015421 const sendEnvMode = resolveSendEnvMode({
54025422 requestedEnvMode: envMode,
......@@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) {
80458065 envMode: mode,
80468066 startFromOrigin: resolveNewDraftStartFromOrigin({
80478067 envMode: mode,
8048 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
8068 newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
80498069 }),
80508070 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
80518071 });
......@@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) {
80578077 composerDraftTarget,
80588078 draftThread?.worktreePath,
80598079 isLocalDraftThread,
8060 primaryServerSettings.newWorktreesStartFromOrigin,
8080 activeProjectSettings.settings.newWorktreesStartFromOrigin,
80618081 setPendingServerThreadEnvMode,
80628082 scheduleComposerFocus,
80638083 setDraftThreadContext,
apps/web/src/components/CommandPalette.tsx+1-4
......@@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: {
18141814 },
18151815 });
18161816
1817 // There is no projects listing page; the action targets the contextual
1818 // project (active thread/draft, falling back to the first sidebar group).
1817 // Target the active thread or draft's project, falling back to the first sidebar group.
18191818 const contextualProjectGroup =
18201819 (contextualProjectRef
18211820 ? projectGroupByTargetKey.get(
......@@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: {
18671866 run: async () => {
18681867 await navigate({
18691868 to: item.to,
1870 search: (previous) =>
1871 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
18721869 hash: item.targetId ?? item.id,
18731870 replace: pathname === item.to,
18741871 hashScrollIntoView: false,
apps/web/src/components/chat/DraftHeroHeadline.tsx+8-5
......@@ -27,6 +27,7 @@ import {
2727 MenuTrigger,
2828} from "../ui/menu";
2929import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3031
3132interface DraftHeroHeadlineProps {
3233 readonly draftId: DraftId | null;
......@@ -150,11 +151,13 @@ export function DraftHeroHeadline({
150151 );
151152 if (!hasExplicitComposerModelSelection(currentDraft)) {
152153 applyStickyState(draftId);
153 const defaultModelSelection =
154 project.defaultModelSelection ??
155 environments.find(
156 (environment) => environment.environmentId === project.environmentId,
157 )?.serverConfig?.settings.defaultModelSelection;
154 const environmentSettings = environments.find(
155 (environment) => environment.environmentId === project.environmentId,
156 )?.serverConfig?.settings;
157 const defaultModelSelection = environmentSettings
158 ? resolveProjectSettings(environmentSettings, project.id, project).settings
159 .defaultModelSelection
160 : project.defaultModelSelection;
158161 if (defaultModelSelection) {
159162 setModelSelection(draftId, defaultModelSelection, {
160163 replaceOptions: true,
apps/web/src/components/chat/ProviderModelPicker.test.tsx+15
......@@ -34,6 +34,7 @@ function renderPicker(input: {
3434 model: string;
3535 options: ReadonlyArray<ModelEsque>;
3636 includeEntry?: boolean;
37 triggerLabel?: string;
3738}) {
3839 const instanceId = ProviderInstanceId.make(input.instanceId);
3940 const entry = providerEntry(input.instanceId, input.driver);
......@@ -45,11 +46,25 @@ function renderPicker(input: {
4546 instanceEntries={input.includeEntry === false ? [] : [entry]}
4647 modelOptionsByInstance={new Map([[instanceId, input.options]])}
4748 onInstanceModelChange={() => {}}
49 {...(input.triggerLabel ? { triggerLabel: input.triggerLabel } : {})}
4850 />,
4951 );
5052}
5153
5254describe("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
5368 it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(
5469 "shows a choice prompt before Antigravity has an account catalog for %s",
5570 (model) => {
apps/web/src/components/chat/ProviderModelPicker.tsx+6-4
......@@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4848 open?: boolean;
4949 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
5050 triggerClassName?: string;
51 /** Aggregate settings can show a neutral value without claiming one provider is selected. */
52 triggerLabel?: string;
5153 triggerAriaLabel?: string;
5254 onOpenChange?: (open: boolean) => void;
5355 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
......@@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
181183 <span
182184 className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")}
183185 >
184 {activeEntry ? (
186 {activeEntry && props.triggerLabel === undefined ? (
185187 <ProviderInstanceIcon
186188 driverKind={activeEntry.driverKind}
187189 displayName={activeEntry.displayName}
......@@ -205,11 +207,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
205207 />
206208 }
207209 >
208 {triggerTitle}
210 {props.triggerLabel ?? triggerTitle}
209211 </TooltipTrigger>
210 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
212 <TooltipPopup side="top">{props.triggerLabel ?? triggerLabel}</TooltipPopup>
211213 </Tooltip>
212 {selectedModel?.isUnavailable ? (
214 {selectedModel?.isUnavailable && props.triggerLabel === undefined ? (
213215 <Badge variant="outline" size="sm">
214216 Unavailable
215217 </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 {
1616 PlayIcon,
1717 WrenchIcon,
1818} 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
2128import {
2229 keybindingValueForCommand,
......@@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({
156163 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
157164 const [validationError, setValidationError] = useState<string | null>(null);
158165 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
166 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);
167 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
159168
160169 const isOpen = request !== null;
161170 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
163183 // Hydrate the form whenever a new request opens the dialog.
164184 useEffect(() => {
......@@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({
172192 setPreviewUrl(request.initial.previewUrl ?? "");
173193 setAutoOpenPreview(request.initial.autoOpenPreview);
174194 setValidationError(request.error ?? null);
195 setSavingRequest(null);
175196 }, [request]);
176197
198 const close = () => {
199 pendingSubmissionRef.current = null;
200 setSavingRequest(null);
201 setIconPickerOpen(false);
202 onClose();
203 };
204
177205 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
178206 if (event.key === "Tab") return;
179207 event.preventDefault();
......@@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({
188216
189217 const submit = async (event: FormEvent) => {
190218 event.preventDefault();
191 if (!request) return;
219 if (!request || pendingSubmissionRef.current !== null) return;
192220 const trimmedName = name.trim();
193221 const trimmedCommand = command.trim();
194222 if (trimmedName.length === 0) {
......@@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({
228256 return;
229257 }
230258
231 const result = await onSubmit(request.scriptId, payload);
232 if (result._tag === "Failure") {
233 if (!isAtomCommandInterrupted(result)) {
234 const error = squashAtomCommandFailure(result);
259 const submission = { request };
260 pendingSubmissionRef.current = submission;
261 setSavingRequest(request);
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) {
235277 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
236278 }
237 return;
238279 }
239 setIconPickerOpen(false);
240 onClose();
280 if (pendingSubmissionRef.current === submission) {
281 pendingSubmissionRef.current = null;
282 setSavingRequest(null);
283 }
241284 };
242285
243286 return (
......@@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({
246289 open={isOpen}
247290 onOpenChange={(open) => {
248291 if (!open) {
249 setIconPickerOpen(false);
250 onClose();
292 close();
251293 }
252294 }}
253295 >
......@@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({
259301 </DialogDescription>
260302 </DialogHeader>
261303 <DialogPanel>
262 <form id={formId} className="space-y-4" onSubmit={submit}>
263 <div className="space-y-1.5">
264 <Label htmlFor="script-name">Name</Label>
265 <div className="flex items-center gap-2">
266 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
267 <PopoverTrigger
268 render={
269 <Button
270 type="button"
271 variant="outline"
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"
273 aria-label="Choose icon"
274 />
275 }
276 >
277 <ScriptIcon icon={icon} className="size-4.5" />
278 </PopoverTrigger>
279 <PopoverPopup align="start">
280 <div className="grid grid-cols-3 gap-2">
281 {SCRIPT_ICONS.map((entry) => {
282 const isSelected = entry.id === icon;
283 return (
284 <button
285 key={entry.id}
286 type="button"
287 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
288 isSelected
289 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
290 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
291 }`}
292 onClick={() => {
293 setIcon(entry.id);
294 setIconPickerOpen(false);
295 }}
296 >
297 <ScriptIcon icon={entry.id} className="size-4" />
298 <span>{entry.label}</span>
299 </button>
300 );
301 })}
302 </div>
303 </PopoverPopup>
304 </Popover>
304 <form id={formId} onSubmit={submit}>
305 <fieldset className="space-y-4" disabled={isSaving}>
306 <div className="space-y-1.5">
307 <Label htmlFor="script-name">Name</Label>
308 <div className="flex items-center gap-2">
309 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
310 <PopoverTrigger
311 render={
312 <Button
313 type="button"
314 variant="outline"
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"
316 aria-label="Choose icon"
317 />
318 }
319 >
320 <ScriptIcon icon={icon} className="size-4.5" />
321 </PopoverTrigger>
322 <PopoverPopup align="start">
323 <div className="grid grid-cols-3 gap-2">
324 {SCRIPT_ICONS.map((entry) => {
325 const isSelected = entry.id === icon;
326 return (
327 <button
328 key={entry.id}
329 type="button"
330 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${
331 isSelected
332 ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30"
333 : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]"
334 }`}
335 onClick={() => {
336 setIcon(entry.id);
337 setIconPickerOpen(false);
338 }}
339 >
340 <ScriptIcon icon={entry.id} className="size-4" />
341 <span>{entry.label}</span>
342 </button>
343 );
344 })}
345 </div>
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>
305359 <Input
306 id="script-name"
307 autoFocus
308 placeholder="Test"
309 value={name}
310 onChange={(event) => setName(event.target.value)}
360 id="script-keybinding"
361 placeholder="Press shortcut"
362 value={keybinding}
363 readOnly
364 onKeyDown={captureKeybinding}
311365 />
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>
312370 </div>
313 </div>
314 <div className="space-y-1.5">
315 <Label htmlFor="script-keybinding">Keybinding</Label>
316 <Input
317 id="script-keybinding"
318 placeholder="Press shortcut"
319 value={keybinding}
320 readOnly
321 onKeyDown={captureKeybinding}
322 />
323 <p className="text-xs text-muted-foreground">
324 Press a shortcut. Use <code>Backspace</code> to clear.
325 </p>
326 </div>
327 <div className="space-y-1.5">
328 <Label htmlFor="script-command">Command</Label>
329 <Textarea
330 id="script-command"
331 placeholder="bun test"
332 value={command}
333 onChange={(event) => setCommand(event.target.value)}
334 />
335 </div>
336 <div className="space-y-1.5">
337 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
338 <Input
339 id="script-preview-url"
340 placeholder="http://localhost:5173"
341 value={previewUrl}
342 onChange={(event) => setPreviewUrl(event.target.value)}
343 />
344 <p className="text-xs text-muted-foreground">
345 Open this URL in the in-app preview when this action runs.
346 </p>
347 </div>
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]">
349 <span>Run automatically on worktree creation</span>
350 <Switch
351 checked={runOnWorktreeCreate}
352 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
353 />
354 </label>
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>}
371 <div className="space-y-1.5">
372 <Label htmlFor="script-command">Command</Label>
373 <Textarea
374 id="script-command"
375 placeholder="bun test"
376 value={command}
377 onChange={(event) => setCommand(event.target.value)}
378 />
379 </div>
380 <div className="space-y-1.5">
381 <Label htmlFor="script-preview-url">Preview URL (optional)</Label>
382 <Input
383 id="script-preview-url"
384 placeholder="http://localhost:5173"
385 value={previewUrl}
386 onChange={(event) => setPreviewUrl(event.target.value)}
387 />
388 <p className="text-xs text-muted-foreground">
389 Open this URL in the in-app preview when this action runs.
390 </p>
391 </div>
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]">
393 <span>Run automatically on worktree creation</span>
394 <Switch
395 checked={runOnWorktreeCreate}
396 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
397 />
398 </label>
399 <label
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] ${
401 previewUrl.trim().length === 0 ? "opacity-60" : ""
402 }`}
403 >
404 <span>Open preview automatically when this action runs</span>
405 <Switch
406 checked={autoOpenPreview}
407 disabled={previewUrl.trim().length === 0}
408 onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))}
409 />
410 </label>
411 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
412 </fieldset>
368413 </form>
369414 </DialogPanel>
370415 <DialogFooter className="dark:border-transparent dark:bg-transparent">
......@@ -373,16 +418,17 @@ export function ProjectScriptEditorDialog({
373418 type="button"
374419 variant="destructive-outline"
375420 className="mr-auto"
421 disabled={isSaving}
376422 onClick={() => setDeleteConfirmOpen(true)}
377423 >
378424 Delete
379425 </Button>
380426 )}
381 <Button type="button" variant="outline" onClick={onClose}>
427 <Button type="button" variant="outline" onClick={close}>
382428 Cancel
383429 </Button>
384 <Button form={formId} type="submit">
385 {isEditing ? "Save changes" : "Save action"}
430 <Button form={formId} type="submit" disabled={isSaving}>
431 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}
386432 </Button>
387433 </DialogFooter>
388434 </DialogPopup>
......@@ -398,10 +444,11 @@ export function ProjectScriptEditorDialog({
398444 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
399445 <Button
400446 variant="destructive"
447 disabled={isSaving}
401448 onClick={() => {
402449 if (!request?.scriptId) return;
403450 setDeleteConfirmOpen(false);
404 onClose();
451 close();
405452 onDelete(request.scriptId);
406453 }}
407454 >
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+20-7
......@@ -5,6 +5,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro
55import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
66import {
77 type EnvironmentId,
8 DEFAULT_SERVER_SETTINGS,
89 type PullRequestAction,
910 type PullRequestMergeMethod,
1011 type PullRequestListEntry,
......@@ -13,6 +14,7 @@ import {
1314 resolveEnvironmentMachineKind,
1415 type ScopedThreadRef,
1516} from "@t3tools/contracts";
17import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
1618import {
1719 ArrowDownUpIcon,
1820 ArrowLeftIcon,
......@@ -55,8 +57,8 @@ import {
5557
5658import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5759import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
58import { useClientSettings } from "~/hooks/useSettings";
5960import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
61import { useClientSettings } from "~/hooks/useSettings";
6062import {
6163 deriveLogicalProjectKeyFromSettings,
6264 derivePhysicalProjectKey,
......@@ -585,10 +587,19 @@ export function PullRequestDetailPanel({
585587 }, [condensed]);
586588 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
587589 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(
589595 (settings) => settings.pullRequestMergeMethodOverrides,
590596 );
591597 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
598 const projectDefaultMergeMethod =
599 resolveProjectSettings(
600 environmentConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS,
601 reference.projectId,
602 ).settings.pullRequestMergeMethod ?? undefined;
592603 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
593604 readonly pullRequestKey: string;
594605 readonly method: PullRequestMergeMethod;
......@@ -834,9 +845,10 @@ export function PullRequestDetailPanel({
834845 )?.repositoryIdentity;
835846 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
836847 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
837 // Project settings store the override under the sidebar group's key, which a duplicate row
848 // Project settings stored the override under the sidebar group's key, which a duplicate row
838849 // 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;
840852 const project = projects.find(
841853 (candidate) =>
842854 candidate.environmentId === environmentId && candidate.id === reference.projectId,
......@@ -849,11 +861,12 @@ export function PullRequestDetailPanel({
849861 primaryEnvironmentId,
850862 }).get(derivePhysicalProjectKey(project)) ??
851863 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings);
852 return mergeMethodOverrides[projectKey];
864 return legacyMergeMethodOverrides[projectKey];
853865 }, [
854866 environmentId,
855 mergeMethodOverrides,
867 legacyMergeMethodOverrides,
856868 primaryEnvironmentId,
869 projectDefaultMergeMethod,
857870 projectGroupingSettings,
858871 projects,
859872 reference.projectId,
......@@ -1327,7 +1340,7 @@ export function PullRequestDetailPanel({
13271340 const selectedMergeMethod = resolvePullRequestMergeMethod(
13281341 allowedMergeMethods,
13291342 currentMergeMethod,
1330 projectDefaultMergeMethod,
1343 projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod,
13311344 lastSelectedMergeMethod,
13321345 );
13331346 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1
......@@ -3133,7 +3133,10 @@ export function ConnectionsSettings() {
31333133 <SettingsPageContainer>
31343134 {canManageLocalBackend ? (
31353135 <>
3136 <SettingsSection {...searchableSetting("connections-environment")}>
3136 <SettingsSection
3137 {...searchableSetting("connections-environment")}
3138 title={primaryEnvironment?.label ?? "Primary environment"}
3139 >
31373140 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
31383141 <SettingsRow
31393142 title={
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-20
......@@ -7,12 +7,11 @@ import {
77 FolderOpenIcon,
88 InfoIcon,
99} from "lucide-react";
10import { useAtomValue } from "@effect/atom-react";
1110import {
1211 isAtomCommandInterrupted,
1312 squashAtomCommandFailure,
1413} 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";
1615import type {
1716 ServerProcessDiagnosticsEntry,
1817 ServerProcessResourceHistorySummary,
......@@ -26,13 +25,8 @@ import { ensureLocalApi } from "../../localApi";
2625import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
2726import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
2827import { useEnvironmentQuery } from "../../state/query";
29import {
30 primaryServerAvailableEditorsAtom,
31 primaryServerObservabilityAtom,
32 serverEnvironment,
33} from "../../state/server";
28import { serverEnvironment } from "../../state/server";
3429import { shellEnvironment } from "../../state/shell";
35import { usePrimaryEnvironment } from "../../state/environments";
3630import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
3731import { Button } from "../ui/button";
3832import { ScrollArea } from "../ui/scroll-area";
......@@ -43,6 +37,7 @@ import { ExpandableText } from "./ExpandableText";
4337import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
4438import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
4539import { useAtomCommand } from "../../state/use-atom-command";
40import { useSettingsScope } from "./SettingsScopeContext";
4641
4742const NUMBER_FORMAT = new Intl.NumberFormat();
4843
......@@ -776,10 +771,12 @@ function DiagnosticsRefreshButton({
776771}
777772
778773export function DiagnosticsSettingsPanel() {
779 const observability = useAtomValue(primaryServerObservabilityAtom);
780 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
781 const primaryEnvironment = usePrimaryEnvironment();
782 const environmentId = primaryEnvironment?.environmentId ?? null;
774 const { environment } = useSettingsScope();
775 // The boundary only mounts this page when the selection resolves to one
776 // connected environment, so the representative is the one to inspect.
777 const environmentId = environment?.environmentId ?? null;
778 const observability = environment?.serverConfig?.observability;
779 const availableEditors = environment?.serverConfig?.availableEditors;
783780 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
784781 reportFailure: false,
785782 });
......@@ -827,8 +824,15 @@ export function DiagnosticsSettingsPanel() {
827824 const signalingPidRef = useRef<number | null>(null);
828825 const environmentIdRef = useRef(environmentId);
829826 const processDataRef = useRef(processData);
830 environmentIdRef.current = environmentId;
831 processDataRef.current = processData;
827 useEffect(() => {
828 processDataRef.current = processData;
829 }, [processData]);
830 useEffect(() => {
831 environmentIdRef.current = environmentId;
832 return () => {
833 environmentIdRef.current = null;
834 };
835 }, [environmentId]);
832836
833837 const openLogsDirectory = useCallback(() => {
834838 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
......@@ -868,6 +872,9 @@ export function DiagnosticsSettingsPanel() {
868872 const isProcessInitialLoading = isProcessPending && processData === null;
869873 const signalProcess = useCallback(
870874 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;
871878 if (signalingPidRef.current !== null) return;
872879 signalingPidRef.current = pid;
873880 setSignalingPid(pid);
......@@ -896,20 +903,21 @@ export function DiagnosticsSettingsPanel() {
896903 return;
897904 }
898905 }
899 const currentEnvironmentId = environmentIdRef.current;
900 if (currentEnvironmentId === null) {
906 if (environmentIdRef.current !== targetEnvironmentId) {
901907 clearSignaling();
902908 return;
903909 }
904 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);
905 if (process === undefined) {
910 if (
911 processDataRef.current?.processes.find((entry) => entry.pid === pid)?.startTimeMs !==
912 process.startTimeMs
913 ) {
906914 clearSignaling();
907915 return;
908916 }
909917
910918 try {
911919 const result = await signalServerProcess({
912 environmentId: currentEnvironmentId,
920 environmentId: targetEnvironmentId,
913921 input: { pid, startTimeMs: process.startTimeMs, signal },
914922 });
915923 if (result._tag === "Failure") {
......@@ -960,7 +968,7 @@ export function DiagnosticsSettingsPanel() {
960968
961969 return (
962970 <SettingsPageContainer width="expanded" className="gap-10">
963 <ResourceTelemetryDiagnostics />
971 <ResourceTelemetryDiagnostics environmentId={environmentId} />
964972
965973 <SettingsSection
966974 title="Live Processes"
apps/web/src/components/settings/IntegrationsSettings.test.tsx+12
......@@ -38,6 +38,18 @@ vi.mock("./settingsLayout", async (importOriginal) => ({
3838 ...(await importOriginal<typeof import("./settingsLayout")>()),
3939 SettingsPageContainer: ({ children }: { children: ReactNode }) => children,
4040}));
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
4254import { IntegrationsSettingsPanel } from "./IntegrationsSettings";
4355import { platformSetupStatus } from "../device/DeviceSetup";
apps/web/src/components/settings/IntegrationsSettings.tsx+16-59
......@@ -36,7 +36,6 @@ import {
3636 type PreviewViewportSetting,
3737} from "@t3tools/contracts";
3838import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
39import { Link } from "@tanstack/react-router";
4039import { MoreVertical, Plus as PlusIcon } from "lucide-react";
4140import { useCallback, useRef, useState } from "react";
4241
......@@ -110,6 +109,8 @@ import {
110109 SettingsSection,
111110} from "./settingsLayout";
112111import { searchableSetting } from "./settingsSearch";
112import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
113import { useSettingsScope } from "./SettingsScopeContext";
113114import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";
114115import type { ImportOutcome } from "./browserImportWizard.logic";
115116
......@@ -564,65 +565,22 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
564565 );
565566}
566567
567function AgentBrowserAccessSetting() {
568 return (
569 <SettingsRow
570 {...searchableSetting("agent-browser-access")}
571 description="Choose whether agents can use the preview browser for all projects or a specific project."
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
568/**
569 * Device support installs helper processes and hosts on one machine, so it
570 * follows the environment crumb. With several environments selected it shows
571 * the representative, named in the section title.
572 */
587573function DeviceIntegrationSettings() {
588 const primaryEnvironment = usePrimaryEnvironment();
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];
574 const { scope, environment: selected, connectedEnvironments } = useSettingsScope();
597575 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
598576 const environmentId = connected ? selected.environmentId : null;
577 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
599578
600579 return (
601 <SettingsSection id="devices" title="Devices">
602 {environments.length > 1 ? (
603 <SettingsRow
604 title="Environment"
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}
580 <SettingsSection
581 id="devices"
582 title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"}
583 >
626584 <DeviceIntegrationControls
627585 key={selected?.environmentId ?? "none"}
628586 environmentId={environmentId}
......@@ -1339,11 +1297,10 @@ export function IntegrationsSettingsPanel() {
13391297
13401298 return (
13411299 <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" />
13421303 <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 />
13471304 {previewDefaultsDisabled ? (
13481305 <SettingsUnavailableGroup message="Only available in the desktop app.">
13491306 {previewDefaults}
apps/web/src/components/settings/KeybindingsSettings.tsx+31-26
......@@ -26,7 +26,7 @@ import {
2626 type ServerRemoveKeybindingInput,
2727 type ServerUpsertKeybindingInput,
2828} from "@t3tools/contracts";
29import { useAtomValue } from "@effect/atom-react";
29import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3030import {
3131 isAtomCommandInterrupted,
3232 squashAtomCommandFailure,
......@@ -36,13 +36,8 @@ import { isElectron } from "../../env";
3636import { useOpenInPreferredEditor } from "../../editorPreferences";
3737import { formatShortcutLabel } from "../../keybindings";
3838import { cn } from "../../lib/utils";
39import {
40 primaryServerAvailableEditorsAtom,
41 primaryServerKeybindingsAtom,
42 primaryServerKeybindingsConfigPathAtom,
43 serverEnvironment,
44} from "../../state/server";
45import { usePrimaryEnvironment } from "../../state/environments";
39import { serverEnvironment } from "../../state/server";
40import { useSettingsScope } from "./SettingsScopeContext";
4641import { Badge } from "../ui/badge";
4742import { Button } from "../ui/button";
4843import { Input } from "../ui/input";
......@@ -1335,10 +1330,13 @@ function BrowserKeybindingNotice() {
13351330}
13361331
13371332export function KeybindingsSettingsPanel() {
1338 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1339 const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom);
1340 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
1341 const primaryEnvironment = usePrimaryEnvironment();
1333 // The representative environment supplies the displayed bindings; edits
1334 // fan out to every connected environment in the selection, so one
1335 // shortcut change reaches each machine the user runs T3 Code on.
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 ?? [];
13421340 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
13431341 reportFailure: false,
13441342 });
......@@ -1411,17 +1409,19 @@ export function KeybindingsSettingsPanel() {
14111409 ...(input.replace ? { replace: input.replace } : {}),
14121410 };
14131411 void (async () => {
1414 const result = await upsertKeybinding({
1415 environmentId: primaryEnvironment.environmentId,
1416 input: payload,
1417 });
1412 const results = await Promise.all(
1413 connectedEnvironments.map((target) =>
1414 upsertKeybinding({ environmentId: target.environmentId, input: payload }),
1415 ),
1416 );
14181417 setSavingCommand(null);
1419 if (result._tag === "Success") {
1418 const failed = results.find((result) => result._tag === "Failure");
1419 if (!failed) {
14201420 setIsAddingBinding(false);
14211421 return;
14221422 }
1423 if (!isAtomCommandInterrupted(result)) {
1424 const error = squashAtomCommandFailure(result);
1423 if (!isAtomCommandInterrupted(failed)) {
1424 const error = squashAtomCommandFailure(failed);
14251425 toastManager.add({
14261426 title: "Unable to save keybinding",
14271427 description: error instanceof Error ? error.message : "The keybinding was not saved.",
......@@ -1430,7 +1430,7 @@ export function KeybindingsSettingsPanel() {
14301430 }
14311431 })();
14321432 },
1433 [primaryEnvironment, upsertKeybinding],
1433 [connectedEnvironments, primaryEnvironment, upsertKeybinding],
14341434 );
14351435
14361436 const removeKeybinding = useCallback(
......@@ -1438,12 +1438,17 @@ export function KeybindingsSettingsPanel() {
14381438 if (!primaryEnvironment) return;
14391439 setSavingCommand(row.command);
14401440 void (async () => {
1441 const result = await removeKeybindingMutation({
1442 environmentId: primaryEnvironment.environmentId,
1443 input: rowKeybindingTarget(row),
1444 });
1441 const results = await Promise.all(
1442 connectedEnvironments.map((target) =>
1443 removeKeybindingMutation({
1444 environmentId: target.environmentId,
1445 input: rowKeybindingTarget(row),
1446 }),
1447 ),
1448 );
14451449 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)) {
14471452 const error = squashAtomCommandFailure(result);
14481453 toastManager.add({
14491454 title: "Unable to remove keybinding",
......@@ -1453,7 +1458,7 @@ export function KeybindingsSettingsPanel() {
14531458 }
14541459 })();
14551460 },
1456 [primaryEnvironment, removeKeybindingMutation],
1461 [connectedEnvironments, primaryEnvironment, removeKeybindingMutation],
14571462 );
14581463
14591464 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 @@
11import {
2 DEFAULT_CLIENT_SETTINGS,
32 DEFAULT_SERVER_SETTINGS,
4 type EnvironmentId,
3 EnvironmentId,
54 type ModelSelection,
65 type ProviderInstanceId,
7 type ServerSettingsPatch,
86} from "@t3tools/contracts";
97import { createModelSelection } from "@t3tools/shared/model";
108import { 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";
1511import { getCustomModelOptionsByInstance } from "../../modelSelection";
1612import {
1713 applyProviderInstanceSettings,
......@@ -19,59 +15,47 @@ import {
1915 resolveDefaultProviderModelSelection,
2016 sortProviderInstanceEntries,
2117} from "../../providerInstances";
22import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
23import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
24import { useAtomCommand } from "../../state/use-atom-command";
18import { useEnvironments } from "../../state/environments";
19import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
2520import { resolveEnvModeLabel } from "../BranchToolbar.logic";
2621import { ProviderModelPicker } from "../chat/ProviderModelPicker";
22import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
2723import { TraitsPicker } from "../chat/TraitsPicker";
2824import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2925import { toastManager } from "../ui/toast";
3026import { Switch } from "../ui/switch";
31import { Button } from "../ui/button";
32import { Input } from "../ui/input";
33import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel";
34import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings";
27import type { ProjectSettingsCategory } from "./ProjectSettingsPanel";
3528import { searchableSetting } from "./settingsSearch";
29import { useSettingsScope } from "./SettingsScopeContext";
3630import {
3731 SETTINGS_PICKER_TRIGGER_CLASSNAME,
3832 SettingResetButton,
39 SettingsPageContainer,
4033 SettingsRow,
4134 SettingsSection,
4235} 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. */
45export function ProjectDefaultsSettings({
46 environmentId,
47}: {
48 environmentId: EnvironmentId | null;
49}) {
50 const { environments } = useEnvironments();
51 const primaryEnvironmentId = usePrimaryEnvironmentId();
52 const clientSettings = useClientSettings();
53 const updateClientSettings = useUpdateClientSettings();
43/**
44 * Rows for the settings a project may override. The same rows edit
45 * environment defaults at an environment scope and project overrides at a
46 * project or checkout scope; the scoped hooks route the write.
47 */
48export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) {
49 const { scope, target, targets, connectedEnvironments } = useSettingsScope();
50 const settings = useScopedSettings();
51 const updateSettings = useUpdateScopedSettings();
5452 const navigate = useNavigate();
55 const updateSettings = useAtomCommand(
56 serverEnvironment.updateSettings,
57 "project defaults update",
58 );
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;
53 const { environments } = useEnvironments();
54 const representative = target
55 ? environments.find((environment) => environment.environmentId === target.environmentId)
56 : undefined;
7157 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
72 const settings = { ...serverSettings, ...clientSettings };
73 const storedSelection = serverSettings.defaultModelSelection;
74 const selection = resolveDefaultProviderModelSelection(providers, storedSelection);
58 const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection);
7559 const entries = sortProviderInstanceEntries(
7660 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
7761 );
......@@ -82,36 +66,46 @@ export function ProjectDefaultsSettings({
8266 selection?.model,
8367 );
8468 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
85 const mixedModel = targets.some(
86 (target) =>
87 JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !==
88 JSON.stringify(storedSelection),
89 );
90 const mixedWorkspace = targets.some(
91 (target) =>
92 target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode,
93 );
94 const mixedBrowser = targets.some(
95 (target) =>
96 target.serverConfig?.settings.enableAgentBrowserAccess !==
97 serverSettings.enableAgentBrowserAccess,
98 );
99 const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key);
100 const mixedAutoPull = targets.some(
101 (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull,
69 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);
70 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
71 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
72 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
73 const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
74 const modelSource = useScopedSettingSource(["defaultModelSelection"]);
75 const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]);
76 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
77 const unavailable = connectedEnvironments.length === 0;
78
79 // A checkout's t3.json wins over the environment default when the project
80 // has no override of its own; show which one "inherit" resolves to.
81 const checkout = scope.kind === "checkout" ? scope.checkout : null;
82 // The query is disabled without a checkout, so any id satisfies the hook.
83 const t3File = useT3ProjectFileState(
84 checkout?.environmentId ?? EnvironmentId.make("none"),
85 category === "general" && checkout ? checkout.workspaceRoot : null,
10286 );
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
10495 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
10596 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
106 for (const target of targets) {
107 const config = target.serverConfig;
97 for (const candidate of targets) {
98 const environment = environments.find(
99 (entry) => entry.environmentId === candidate.environmentId,
100 );
101 const config = environment?.serverConfig;
108102 if (!config) continue;
109103 const entry = applyProviderInstanceSettings(
110104 deriveProviderInstanceEntries(config.providers),
111 config.settings,
112 ).find((candidate) => candidate.instanceId === instanceId);
105 candidate.settings,
106 ).find((option) => option.instanceId === instanceId);
113107 const options = getCustomModelOptionsByInstance(
114 { ...config.settings, ...clientSettings },
108 { ...settings, ...candidate.settings },
115109 config.providers,
116110 ).get(instanceId);
117111 if (
......@@ -120,355 +114,282 @@ export function ProjectDefaultsSettings({
120114 entry.driverKind !== sourceEntry?.driverKind ||
121115 !options?.some((option) => option.slug === model && !option.isUnavailable)
122116 ) {
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.`;
124118 }
125119 }
126120 return null;
127121 }
128122
129 async function save(patch: ServerSettingsPatch) {
130 const keys = Object.keys(patch);
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;
123 const setModel = (value: ModelSelection | null) => {
124 const reason = value ? modelDisabledReason(value.instanceId, value.model) : null;
134125 if (reason) {
135126 toastManager.add({ type: "error", title: "Default model not saved", description: reason });
136127 return;
137128 }
138 for (const key of keys) savingRef.current.add(key);
139 setSaving(new Set(savingRef.current));
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 }
129 updateSettings({ defaultModelSelection: value });
130 };
159131
160 const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value });
161132 return (
162 <SettingsPageContainer>
163 <SettingsSection
164 id={searchableSetting("project-defaults").id}
165 title="Project defaults"
166 hideTitle
167 >
168 <SettingsRow
169 title="Name"
170 aria-disabled
171 description="Select a project to change its name."
172 control={
173 <Input
174 size="sm"
175 className="w-full sm:w-64"
176 aria-label="Project name"
177 placeholder="Select a project"
178 disabled
179 />
180 }
181 />
182 <SettingsRow
183 title="Project icon"
184 aria-disabled
185 description="Select a project to change its icon."
186 control={
187 <div className="flex items-center gap-2">
188 <Button size="sm" variant="outline" disabled>
189 Choose icon
190 </Button>
191 <Button size="sm" variant="outline" disabled>
192 Choose file
193 </Button>
194 </div>
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
133 <SettingsSection
134 id={
135 category === "general"
136 ? "project-defaults"
137 : category === "integrations"
138 ? "browser-access"
139 : "source-control-defaults"
140 }
141 title={
142 category === "general"
143 ? "New threads"
144 : category === "integrations"
145 ? "Browser"
146 : "Repositories"
147 }
148 >
149 {category === "general" ? (
150 <>
151 <SettingsRow
152 serverScoped
153 settingKeys={["defaultModelSelection"]}
154 mixed={mixedModel}
155 id="default-model"
156 title="Model"
157 description={
158 isProjectScope
159 ? "Model for new threads in this project."
160 : "Default model for new threads. Projects can override it."
161 }
162 status={
163 unavailable || mixedModel || modelSource === "project"
164 ? undefined
165 : settings.defaultModelSelection === null
213166 ? "Automatic"
214167 : undefined
215 }
216 resetAction={
217 storedSelection !== null || mixedModel ? (
218 <SettingResetButton
219 label="default model"
220 disabled={disabled("defaultModelSelection")}
221 onClick={() => setModel(null)}
222 />
223 ) : null
224 }
225 control={
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}
168 }
169 resetAction={
170 settings.defaultModelSelection !== null ? (
171 <SettingResetButton label="default model" onClick={() => setModel(null)} />
172 ) : null
173 }
174 control={
175 selection && activeEntry ? (
176 <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
177 <ProviderModelPicker
178 activeInstanceId={selection.instanceId}
256179 model={selection.model}
257 prompt=""
258 onPromptChange={() => {}}
259 modelOptions={selection.options ?? []}
260 allowPromptInjectedEffort={false}
261 planModeEnabled={settings.planModeEnabled}
180 lockedProvider={null}
181 instanceEntries={entries}
182 modelOptionsByInstance={modelOptions}
262183 triggerVariant="outline"
263184 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
264 onModelOptionsChange={(options) =>
265 setModel(createModelSelection(selection.instanceId, selection.model, options))
185 {...(mixedModel ? { triggerLabel: "Mixed" } : {})}
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))
266196 }
267197 />
268 ) : null}
269 </fieldset>
270 ) : (
271 <span className="text-sm text-muted-foreground">No providers available</span>
272 )
273 }
274 />
275 <SettingsRow
276 id={searchableSetting("new-threads").id}
277 title="Workspace"
278 description="Where new threads start, unless overridden by the project or t3.json."
279 resetAction={
280 mixedWorkspace ||
281 serverSettings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
282 <SettingResetButton
283 label="default workspace"
284 disabled={disabled("defaultThreadEnvMode")}
285 onClick={() =>
286 void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode })
287 }
288 />
289 ) : null
290 }
291 control={
292 <Select
293 disabled={disabled("defaultThreadEnvMode")}
294 value={mixedWorkspace ? "mixed" : serverSettings.defaultThreadEnvMode}
295 onValueChange={(value) => {
296 if (value === "local" || value === "worktree")
297 void save({ defaultThreadEnvMode: value });
298 }}
299 >
300 <SelectTrigger size="sm" aria-label="Default workspace">
301 <SelectValue>
302 {targets.length === 0
303 ? "Unavailable"
304 : mixedWorkspace
305 ? "Differs by machine"
306 : resolveEnvModeLabel(serverSettings.defaultThreadEnvMode)}
307 </SelectValue>
308 </SelectTrigger>
309 <SelectPopup align="end" alignItemWithTrigger={false}>
310 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
311 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
312 </SelectPopup>
313 </Select>
314 }
315 />
316 <SettingsRow
317 title="Automatically pull"
318 description="Keeps the default branch current when the checkout has no local changes or commits. Projects can override it."
319 status={mixedAutoPull ? "Differs by machine" : undefined}
320 resetAction={
321 serverSettings.defaultAutoPull || mixedAutoPull ? (
322 <SettingResetButton
323 label="default automatic pull"
324 tooltip="Reset automatic pull to off"
325 disabled={disabled("defaultAutoPull")}
326 onClick={() => void save({ defaultAutoPull: false })}
198 {!mixedModel ? (
199 <TraitsPicker
200 provider={activeEntry.driverKind}
201 models={activeEntry.models}
202 model={selection.model}
203 prompt=""
204 onPromptChange={() => {}}
205 modelOptions={selection.options ?? []}
206 allowPromptInjectedEffort={false}
207 planModeEnabled={settings.planModeEnabled}
208 triggerVariant="outline"
209 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
210 onModelOptionsChange={(options) =>
211 setModel(
212 createModelSelection(selection.instanceId, selection.model, options),
213 )
214 }
215 />
216 ) : null}
217 </div>
218 ) : (
219 <span className="text-sm text-muted-foreground">No providers available</span>
220 )
221 }
222 />
223 <SettingsRow
224 serverScoped
225 settingKeys={["defaultThreadEnvMode"]}
226 mixed={mixedWorkspace}
227 id={searchableSetting("new-threads").id}
228 title="Workspace"
229 description={
230 isProjectScope
231 ? "Where new threads in this project start. A t3.json preference applies when the project has no override."
232 : "Where new threads start, unless overridden by the project or t3.json."
233 }
234 status={
235 inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined
236 }
237 resetAction={
238 settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
239 <SettingResetButton
240 label="default workspace"
241 onClick={() =>
242 updateSettings({
243 defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode,
244 })
245 }
246 />
247 ) : null
248 }
249 control={
250 <Select
251 value={mixedWorkspace ? null : settings.defaultThreadEnvMode}
252 onValueChange={(value) => {
253 if (value === "local" || value === "worktree")
254 updateSettings({ defaultThreadEnvMode: value });
255 }}
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 })}
327304 />
328 ) : null
329 }
330 control={
331 <Switch
332 aria-label="Default automatic pull"
333 checked={serverSettings.defaultAutoPull}
334 disabled={disabled("defaultAutoPull")}
335 onCheckedChange={(enabled) => void save({ defaultAutoPull: enabled })}
336 />
337 }
338 />
339 <SettingsRow
340 id={searchableSetting("agent-browser-access").id}
341 title="Agent browser access"
342 description="Allow agents to use the shared browser. Projects can override it."
343 resetAction={
344 mixedBrowser ||
345 serverSettings.enableAgentBrowserAccess !==
305 }
306 />
307 <SettingsRow
308 serverScoped
309 settingKeys={["pullRequestMergeMethod"]}
310 mixed={mixedMergeMethod}
311 {...searchableSetting("pull-request-merge-method")}
312 description={
313 isProjectScope
314 ? "Pull requests in this project start with this method."
315 : "Pull requests start with this method. Last selected reuses whatever you chose most recently on this device."
316 }
317 resetAction={
318 settings.pullRequestMergeMethod !== null ? (
319 <SettingResetButton
320 label="default merge method"
321 tooltip="Reset to last selected"
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 !==
346371 DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? (
347 <SettingResetButton
348 label="default browser access"
349 disabled={disabled("enableAgentBrowserAccess")}
350 onClick={() =>
351 void save({
352 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,
353 })
354 }
355 />
356 ) : null
357 }
358 control={
359 <Select
360 disabled={disabled("enableAgentBrowserAccess")}
361 value={
362 mixedBrowser
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 }
372 <SettingResetButton
373 label="default browser access"
374 onClick={() =>
375 updateSettings({
376 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,
377 })
378 }
379 />
380 ) : null
381 }
382 control={
383 <Switch
384 aria-label="Agent browser access"
385 mixed={mixedBrowser}
386 checked={mixedBrowser ? false : settings.enableAgentBrowserAccess}
387 onCheckedChange={(enabled) => updateSettings({ enableAgentBrowserAccess: enabled })}
418388 />
419 ) : null
420 }
421 control={
422 <Select
423 value={clientSettings.sidebarProjectGroupingMode}
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>
389 }
390 />
391 </>
392 )}
393 </SettingsSection>
473394 );
474395}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+54-977
......@@ -1,4 +1,3 @@
1import { useAtomValue } from "@effect/atom-react";
21import {
32 isAtomCommandInterrupted,
43 mapAtomCommandResult,
......@@ -8,102 +7,28 @@ import {
87} from "@t3tools/client-runtime/state/runtime";
98import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
109import { AsyncResult } from "effect/unstable/reactivity";
11import {
12 deriveProjectGroupingOverrideKey,
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";
10import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
11import { useLocation, useNavigate } from "@tanstack/react-router";
3912import * as Cause from "effect/Cause";
40import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";
13import { Trash2Icon } from "lucide-react";
4114import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
4215
4316import { 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";
6117import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
6218import { readLocalApi } from "../../localApi";
63import {
64 applyProviderInstanceSettings,
65 deriveProviderInstanceEntries,
66 resolveDefaultProviderModelSelection,
67 sortProviderInstanceEntries,
68} from "../../providerInstances";
69import { getCustomModelOptionsByInstance } from "../../modelSelection";
7019import {
7120 type SidebarProjectGroupMember,
7221 type SidebarProjectSnapshot,
7322} from "../../sidebarProjectGrouping";
7423import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
75import { useProjects, useThreadShells } from "../../state/entities";
24import { useThreadShells } from "../../state/entities";
7625import { projectEnvironment } from "../../state/projects";
77import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
7826import { useAtomCommand } from "../../state/use-atom-command";
79import { ProviderModelPicker } from "../chat/ProviderModelPicker";
80import { TraitsPicker } from "../chat/TraitsPicker";
8127import { 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";
9128import { Button } from "../ui/button";
9229import { 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";
10430import { stackedThreadToast, toastManager } from "../ui/toast";
10531import {
106 SETTINGS_PICKER_TRIGGER_CLASSNAME,
10732 SettingResetButton,
10833 SettingsPageContainer,
10934 SettingsRow,
......@@ -113,6 +38,7 @@ import {
11338 canPickExternalProjectFavicon,
11439 ProjectFaviconPickerDialog,
11540} from "./ProjectFaviconPickerDialog";
41import { ProjectActionsSettings } from "./ProjectActionsSettings";
11642import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
11743import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
11844
......@@ -122,16 +48,12 @@ const ProjectIconPickerDialog = lazy(() =>
12248 })),
12349);
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
13151function memberKey(member: { environmentId: string; id: string }): string {
13252 return `${member.environmentId}:${member.id}`;
13353}
13454
55export type ProjectSettingsCategory = "general" | "integrations" | "source-control";
56
13557export function ProjectSettingsPanel({
13658 projectKey,
13759 environmentId = null,
......@@ -142,7 +64,8 @@ export function ProjectSettingsPanel({
14264 checkoutKey?: string | null;
14365}) {
14466 const groups = useSettingsProjectGroups();
145 const navigate = useNavigate();
67 const navigate = useNavigate({ from: "/settings" });
68 const pathname = useLocation({ select: (location) => location.pathname });
14669
14770 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
14871 const members = useMemo(
......@@ -189,17 +112,17 @@ export function ProjectSettingsPanel({
189112 );
190113 if (successor) {
191114 void navigate({
192 to: "/settings/projects",
193 search: {
115 to: pathname,
116 search: () => ({
194117 project: successor.projectKey,
195118 machine: environmentId ?? undefined,
196119 checkout: checkoutKey ?? undefined,
197 },
120 }),
198121 replace: true,
199122 hashScrollIntoView: false,
200123 });
201124 }
202 }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]);
125 }, [groups, navigate, pathname, projectKey, members.length, environmentId, checkoutKey]);
203126
204127 if (!selected) {
205128 return (
......@@ -231,146 +154,6 @@ export function ProjectSettingsPanel({
231154 );
232155}
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
374157function ProjectDetail({
375158 group,
376159 hasOtherMembers,
......@@ -378,7 +161,7 @@ function ProjectDetail({
378161 group: SidebarProjectSnapshot;
379162 hasOtherMembers: boolean;
380163}) {
381 const navigate = useNavigate();
164 const navigate = useNavigate({ from: "/settings" });
382165 const primaryEnvironmentId = usePrimaryEnvironmentId();
383166 const { environments } = useEnvironments();
384167 const environmentById = useMemo(
......@@ -389,102 +172,10 @@ function ProjectDetail({
389172 group.memberProjects.find(
390173 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
391174 ) ?? 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);
401175 const threads = useThreadShells();
402176 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);
473177 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
474178 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
489180 const faviconPath = representative.faviconPath ?? null;
490181 const projectIcon = representative.projectIcon ?? null;
......@@ -516,14 +207,23 @@ function ProjectDetail({
516207 async (
517208 input: Partial<{
518209 title: string;
519 defaultModelSelection: ModelSelection | null;
520 defaultThreadEnvMode: ThreadEnvMode | null;
521 autoPull: boolean;
522210 faviconPath: string | null;
523211 projectIcon: ProjectIconOverride | null;
524212 }>,
525213 failureTitle: string,
526214 ): 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 }
527227 for (const member of group.memberProjects) {
528228 const result = mapAtomCommandResult(
529229 await updateProject({
......@@ -546,7 +246,7 @@ function ProjectDetail({
546246 }
547247 return AsyncResult.success(undefined);
548248 },
549 [group.memberProjects, reportFailure, updateProject],
249 [environmentById, group.memberProjects, reportFailure, updateProject],
550250 );
551251
552252 const renameGroup = useCallback(
......@@ -570,117 +270,6 @@ function ProjectDetail({
570270 [group.memberProjects, updateAllMembers],
571271 );
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
684273 // ----- project icon -----
685274 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
686275 const [iconPickerOpen, setIconPickerOpen] = useState(false);
......@@ -701,99 +290,7 @@ function ProjectDetail({
701290 [updateAllMembers],
702291 );
703292
704 // ----- checkout selection and scripts -----
705293 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
798295 const removeMembers = useCallback(
799296 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
......@@ -884,26 +381,32 @@ function ProjectDetail({
884381 ],
885382 );
886383
887 const selectedCheckoutGrouping =
888 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
889 deriveProjectGroupingOverrideKey(selectedCheckout)
890 ] ?? "inherit";
891 const checkoutLabel = (member: SidebarProjectGroupMember) => {
892 const label = member.environmentLabel ?? "This machine";
893 return group.memberProjects.some(
894 (other) =>
895 other.physicalProjectKey !== member.physicalProjectKey &&
896 (other.environmentLabel ?? "This machine") === label,
897 )
898 ? `${label} · ${member.workspaceRoot}`
899 : label;
900 };
901 const selectedCheckoutLabel = checkoutLabel(selectedCheckout);
384 const checkoutChoices = (
385 <SettingsSection title="Checkouts">
386 {group.memberProjects.map((member) => (
387 <SettingsRow
388 key={member.physicalProjectKey}
389 title={member.environmentLabel ?? "Environment"}
390 description={member.workspaceRoot}
391 control={
392 <Button
393 size="sm"
394 variant="outline"
395 onClick={() => void removeMembers([member])}
396 aria-label={`Remove checkout ${member.workspaceRoot}`}
397 >
398 Remove
399 </Button>
400 }
401 />
402 ))}
403 </SettingsSection>
404 );
902405
903406 return (
904407 <>
905408 <SettingsPageContainer className="gap-6">
906 <SettingsSection title="Project" hideTitle>
409 <SettingsSection id="project-overview" title="Project" hideTitle>
907410 <SettingsRow
908411 title="Name"
909412 description="The shared name for this project group in the sidebar and thread lists."
......@@ -938,7 +441,9 @@ function ProjectDetail({
938441 : (faviconPath ?? "Automatic")
939442 }
940443 resetAction={
941 faviconPath !== null || projectIcon !== null ? (
444 group.memberProjects.some(
445 (member) => member.faviconPath != null || member.projectIcon != null,
446 ) ? (
942447 <SettingResetButton
943448 label="project icon"
944449 disabled={isSavingFavicon}
......@@ -972,430 +477,9 @@ function ProjectDetail({
972477 </div>
973478 }
974479 />
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 />
1222480 </SettingsSection>
1223
1224 <SettingsSection title="Checkout">
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
481 <ProjectActionsSettings />
482 {hasMultipleCheckouts ? checkoutChoices : null}
1399483 <SettingsSection title="Danger">
1400484 <SettingsRow
1401485 title={
......@@ -1430,13 +514,6 @@ function ProjectDetail({
1430514 </SettingsSection>
1431515 </SettingsPageContainer>
1432516
1433 <ProjectScriptEditorDialog
1434 request={editorRequest}
1435 scripts={scripts}
1436 onSubmit={submitScript}
1437 onDelete={deleteScript}
1438 onClose={() => setEditorRequest(null)}
1439 />
1440517 <ProjectFaviconPickerDialog
1441518 key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}
1442519 cwd={representative.workspaceRoot}
apps/web/src/components/settings/ProjectsSettings.tsx+10-30
......@@ -1,21 +1,12 @@
1import { WorkspacePageContainer } from "../WorkspacePageContainer";
2import { useEnvironments } from "../../state/environments";
1import { EnvironmentId } from "@t3tools/contracts";
2
33import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
4import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
5import { SettingsScopeSelects } from "./SettingsScopeSelects";
6import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
7import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
4import { useSettingsScope } from "./SettingsScopeContext";
5import { SettingsScopeNotice } from "./SettingsScopeNotice";
86
9export function ProjectsSettings({
10 value,
11 onScopeChange,
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);
7/** Project identity and checkout management for the selected project. */
8export function ProjectsSettings() {
9 const { search: value, scope } = useSettingsScope();
1910 // The panel follows remembered members when grouping replaces a project key.
2011 const projectScope =
2112 scope.kind === "project" ||
......@@ -24,16 +15,6 @@ export function ProjectsSettings({
2415 (scope.reason === "project-missing" || scope.reason === "checkout-missing"));
2516 return (
2617 <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>
3718 {value.project && projectScope ? (
3819 <ProjectSettingsPanel
3920 projectKey={value.project}
......@@ -43,11 +24,10 @@ export function ProjectsSettings({
4324 ) : scope.kind === "unavailable" ? (
4425 <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>
4526 ) : (
46 <ProjectDefaultsSettings
47 environmentId={scope.kind === "environment" ? scope.environmentId : null}
48 />
27 <SettingsScopeNotice target="project">
28 Choose a project to manage its name, icon, checkouts and actions.
29 </SettingsScopeNotice>
4930 )}
5031 </div>
5132 );
5233}
53import { EnvironmentId } from "@t3tools/contracts";
apps/web/src/components/settings/ProviderInstanceCard.tsx+4
......@@ -896,6 +896,10 @@ export function ProviderInstanceCard({
896896 className={readOnly ? "opacity-50 select-none" : undefined}
897897 >
898898 <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>
899903 <ProviderModelsSection
900904 instanceId={instanceId}
901905 driverKind={driverKind}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+28-5
......@@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({
2929 readEnvironmentIds: [] as EnvironmentId[],
3030 updateEnvironmentIds: [] as EnvironmentId[],
3131 updateSettings: vi.fn(),
32 updateClientSettings: vi.fn(),
3233}));
3334
3435const settingsSearchState = vi.hoisted(() => ({
......@@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({
8182}));
8283
8384vi.mock("../../hooks/useSettings", () => ({
85 useUpdateClientSettings: () => settingsState.updateClientSettings,
8486 useEnvironmentSettings: (environmentId: EnvironmentId) => {
8587 settingsState.readEnvironmentIds.push(environmentId);
8688 return settingsState.value;
8789 },
88 useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => {
89 settingsState.updateEnvironmentIds.push(environmentId);
90 return settingsState.updateSettings;
91 },
90 useUpdateEnvironmentSettings: () => settingsState.updateSettings,
9291}));
9392
9493vi.mock("../../environments/primary", () => ({
......@@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => {
177176 settingsState.readEnvironmentIds = [];
178177 settingsState.updateEnvironmentIds = [];
179178 settingsState.updateSettings.mockReset();
179 settingsState.updateClientSettings.mockReset();
180180 settingsSearchState.targetId = null;
181181 settingsSearchState.effects = [];
182182 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
......@@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => {
186186 it("coalesces a nullable provider snapshot before rendering array-backed UI", () => {
187187 expect(() => renderPanel()).not.toThrow();
188188 expect(settingsState.readEnvironmentIds).toEqual([environmentId]);
189 expect(settingsState.updateEnvironmentIds).toEqual([environmentId]);
190189 });
191190
192191 it("routes refresh and provider update commands to the selected environment", async () => {
......@@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => {
230229 expect(editor?.props.instanceId).toBe(customId);
231230 });
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
233256 it("does not substitute another account when the requested instance was removed", () => {
234257 atoms.providers = [provider()];
235258 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
3131import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
3232import { isElectron } from "../../env";
3333import { usePrimarySessionState } from "../../environments/primary";
34import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
34import {
35 useEnvironmentSettings,
36 useUpdateClientSettings,
37 useUpdateEnvironmentSettings,
38} from "../../hooks/useSettings";
3539import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
3640import { cn } from "../../lib/utils";
3741import { resolveAppModelSelectionState } from "../../modelSelection";
......@@ -262,6 +266,7 @@ function EnvironmentUnavailablePlaceholder({
262266interface ProviderSettingsTarget {
263267 readonly environmentId?: EnvironmentId;
264268 readonly instanceId?: ProviderInstanceId;
269 readonly scoped?: boolean;
265270}
266271
267272export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
......@@ -293,9 +298,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
293298 target.environmentId !== undefined &&
294299 selectedEnvironmentId === target.environmentId &&
295300 !options.some((environment) => environment.environmentId === target.environmentId);
296 const effectiveEnvironmentId = targetEnvironmentMissing
297 ? target.environmentId
298 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);
301 const effectiveEnvironmentId =
302 target.scoped || targetEnvironmentMissing
303 ? target.environmentId
304 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);
299305 const selectedEnvironment =
300306 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
301307 const selectedEnvironmentCanRenderSettings =
......@@ -312,6 +318,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
312318 )?.environmentId;
313319 useEffect(() => {
314320 if (
321 !target.scoped &&
315322 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
316323 searchTargetId === searchableSetting("usage-providers").id) &&
317324 !selectedEnvironmentCanRenderSettings &&
......@@ -319,11 +326,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
319326 ) {
320327 setSelectedEnvironmentId(searchableEnvironmentId);
321328 }
322 }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]);
329 }, [
330 searchTargetId,
331 searchableEnvironmentId,
332 selectedEnvironmentCanRenderSettings,
333 target.scoped,
334 ]);
323335 const onlyPrimaryDevice =
324336 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
325337 const deviceTabs =
326 !onlyPrimaryDevice && options.length > 0 ? (
338 !target.scoped && !onlyPrimaryDevice && options.length > 0 ? (
327339 <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 flex-1 rounded-none">
328340 <ToggleGroup
329341 aria-label="Devices"
......@@ -559,7 +571,10 @@ export function EnvironmentProviderSettings({
559571 readonly readOnly?: boolean;
560572}) {
561573 const settings = useEnvironmentSettings(environmentId);
574 // Provider instances hold per-machine credentials and binaries, so this
575 // page always edits exactly the environment it displays.
562576 const updateSettings = useUpdateEnvironmentSettings(environmentId);
577 const updateClientSettings = useUpdateClientSettings();
563578 const serverProviders =
564579 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
565580 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
......@@ -815,7 +830,7 @@ export function EnvironmentProviderSettings({
815830 const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))];
816831 const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))];
817832 const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId);
818 updateSettings({
833 updateClientSettings({
819834 providerModelPreferences:
820835 hiddenModels.length === 0 && modelOrder.length === 0
821836 ? rest
......@@ -841,7 +856,7 @@ export function EnvironmentProviderSettings({
841856 }),
842857 ),
843858 ];
844 updateSettings({
859 updateClientSettings({
845860 favorites: [
846861 ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId),
847862 ...favoriteModels.map((model) => ({ provider: instanceId, model })),
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+26-15
......@@ -13,6 +13,7 @@ import {
1313} from "lucide-react";
1414import type {
1515 BackgroundBooleanState,
16 EnvironmentId,
1617 ResourceAttributionEntry,
1718 ResourceTelemetryAggregate,
1819 ResourceTelemetryHistoryBucket,
......@@ -26,7 +27,7 @@ import type {
2627} from "@t3tools/contracts";
2728import * as DateTime from "effect/DateTime";
2829import * as Option from "effect/Option";
29import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
30import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3031import {
3132 isAtomCommandInterrupted,
3233 squashAtomCommandFailure,
......@@ -38,7 +39,6 @@ import {
3839} from "../../lib/resourceTelemetryState";
3940import { cn } from "../../lib/utils";
4041import { ensureLocalApi } from "../../localApi";
41import { usePrimaryEnvironment } from "../../state/environments";
4242import { serverEnvironment } from "../../state/server";
4343import { useAtomCommand } from "../../state/use-atom-command";
4444import { formatRelativeTime } from "../../timestampFormat";
......@@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttribut
831831 );
832832}
833833
834export function ResourceTelemetryDiagnostics() {
834export function ResourceTelemetryDiagnostics({
835 environmentId,
836}: {
837 environmentId: EnvironmentId | null;
838}) {
835839 const [windowMs, setWindowMs] = useState(15 * 60_000);
836840 const selectedWindow =
837841 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
838 const telemetry = useResourceTelemetry();
842 const telemetry = useResourceTelemetry(environmentId);
839843 const retryTelemetry = telemetry.retry;
840 const history = useResourceTelemetryHistory({
841 windowMs: selectedWindow.windowMs,
842 bucketMs: selectedWindow.bucketMs,
843 });
844 const primaryEnvironment = usePrimaryEnvironment();
844 const history = useResourceTelemetryHistory(
845 {
846 windowMs: selectedWindow.windowMs,
847 bucketMs: selectedWindow.bucketMs,
848 },
849 environmentId,
850 );
845851 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
846852 reportFailure: false,
847853 });
848854 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());
849855 const signalingKeysRef = useRef<ReadonlySet<string>>(new Set());
850 signalingKeysRef.current = signalingKeys;
851 const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId);
852 primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId;
856 const environmentIdRef = useRef(environmentId);
857 useEffect(() => {
858 environmentIdRef.current = environmentId;
859 return () => {
860 environmentIdRef.current = null;
861 };
862 }, [environmentId]);
853863 const [isRetrying, setIsRetrying] = useState(false);
854864 const snapshot = telemetry.data;
855865 const allT3 = snapshot?.groups.allT3;
856866
857867 const signalProcess = useCallback(
858868 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
869 const targetEnvironmentId = environmentIdRef.current;
870 if (targetEnvironmentId === null) return;
859871 const identityKey = processIdentityKey(process);
860872 if (signalingKeysRef.current.has(identityKey)) return;
861873 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
......@@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() {
889901 return;
890902 }
891903 }
892 const environmentId = primaryEnvironmentIdRef.current;
893 if (environmentId === undefined) {
904 if (environmentIdRef.current !== targetEnvironmentId) {
894905 clearSignaling();
895906 return;
896907 }
897908 void signalServerProcess({
898 environmentId,
909 environmentId: targetEnvironmentId,
899910 input: {
900911 pid: process.identity.pid,
901912 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 @@
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";
119import {
220 WorkspaceBreadcrumb,
321 WorkspaceBreadcrumbItem,
422 WorkspaceBreadcrumbSeparator,
523} from "../WorkspaceBreadcrumb";
624import { 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
836const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
937 ...SETTINGS_SECTION_LABELS,
......@@ -15,7 +43,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null {
1543 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
1644}
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}) {
1967 const sectionLabel = settingsBreadcrumbLabel(pathname);
2068
2169 return (
......@@ -29,6 +77,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
2977 <WorkspaceBreadcrumbItem current className="truncate">
3078 {sectionLabel ?? "Settings"}
3179 </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}
3292 </WorkspaceBreadcrumb>
3393 );
3494}
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
33import { Link, useNavigate } from "@tanstack/react-router";
44import type { CSSProperties, ReactNode } from "react";
55import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6import { useAtomValue } from "@effect/atom-react";
76import {
87 type BackgroundActivityProfile,
98 type DesktopUpdateChannel,
......@@ -70,13 +69,19 @@ import {
7069 useTheme,
7170} from "../../hooks/useTheme";
7271import { 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";
7480import { useThreadActions } from "../../hooks/useThreadActions";
7581import { useDesktopUpdateState } from "../../state/desktopUpdate";
7682import {
7783 getCustomModelOptionsByInstance,
7884 resolveAppModelSelectionState,
79 withoutPlanAgentSelection,
8085} from "../../modelSelection";
8186import {
8287 applyProviderInstanceSettings,
......@@ -85,13 +90,7 @@ import {
8590} from "../../providerInstances";
8691import { ensureLocalApi, readLocalApi } from "../../localApi";
8792import { isMacPlatform } from "../../lib/utils";
88import {
89 primaryServerConfigAtom,
90 primaryServerObservabilityAtom,
91 primaryServerProvidersAtom,
92} from "../../state/server";
93import { useProjects } from "../../state/entities";
94import { usePrimaryEnvironmentId } from "../../state/environments";
93import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
9594import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
9695import { formatRelativeTimeLabel } from "../../timestampFormat";
9796import { Button } from "../ui/button";
......@@ -118,7 +117,6 @@ import {
118117 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
119118} from "../../appearanceFonts";
120119import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
121import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
122120import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
123121import {
124122 NumberField,
......@@ -129,6 +127,7 @@ import {
129127} from "../ui/number-field";
130128import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
131129import { Switch } from "../ui/switch";
130import { ScopedSwitch } from "./ScopedSwitch";
132131import { stackedThreadToast, toastManager } from "../ui/toast";
133132import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
134133import { ThemeLibrary } from "./ThemeSettings";
......@@ -136,7 +135,6 @@ import {
136135 backgroundActivityOverrideSettings,
137136 backgroundActivitySharedPolicySettings,
138137 durationToSeconds,
139 formatDiagnosticsDescription,
140138 getChangedBrowserSettingLabels,
141139 getChangedTypographySettingLabels,
142140 normalizeIntervalSeconds,
......@@ -488,8 +486,8 @@ export function useSettingsRestore(onRestored?: () => void) {
488486 clearThemeHalves,
489487 themeHalves,
490488 } = useTheme();
491 const settings = usePrimarySettings();
492 const updateSettings = useUpdatePrimarySettings();
489 const settings = useScopedSettings();
490 const updateSettings = useUpdateScopedSettings();
493491
494492 const isTextGenerationModelDirty = !Equal.equals(
495493 settings.textGenerationModelSelection ?? null,
......@@ -781,8 +779,8 @@ function BackgroundActivityAdvancedDialog({
781779 readonly open: boolean;
782780 readonly onOpenChange: (open: boolean) => void;
783781}) {
784 const settings = usePrimarySettings();
785 const updateSettings = useUpdatePrimarySettings();
782 const settings = useScopedSettings();
783 const updateSettings = useUpdateScopedSettings();
786784 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
787785 const activeProfile = resolvedBackgroundActivity.profile;
788786 const automaticGitFetchIntervalSeconds = durationToSeconds(
......@@ -1059,8 +1057,8 @@ export function AppearanceSettingsPanel() {
10591057 } = useTheme();
10601058 const customThemes = useCustomThemes();
10611059 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
1062 const settings = usePrimarySettings();
1063 const updateSettings = useUpdatePrimarySettings();
1060 const settings = useScopedSettings();
1061 const updateSettings = useUpdateScopedSettings();
10641062 const environmentStageLabel = useEnvironmentStageLabel();
10651063 const showEnvironmentIdentification =
10661064 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
......@@ -1353,7 +1351,7 @@ export function AppearanceSettingsPanel() {
13531351}
13541352
13551353function useFontDefaultFamilies() {
1356 const settings = usePrimarySettings();
1354 const settings = useScopedSettings();
13571355 // An unset preference shows the font it resolves to on this machine; the
13581356 // default stacks are the platform's own faces, so the name is probed, not
13591357 // hardcoded.
......@@ -1373,8 +1371,8 @@ function useFontDefaultFamilies() {
13731371}
13741372
13751373function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
1376 const settings = usePrimarySettings();
1377 const updateSettings = useUpdatePrimarySettings();
1374 const settings = useScopedSettings();
1375 const updateSettings = useUpdateScopedSettings();
13781376 const defaults = useFontDefaultFamilies();
13791377 return (
13801378 <FontFamilySettingsRow
......@@ -1404,8 +1402,8 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
14041402}
14051403
14061404function PromptFontRow() {
1407 const settings = usePrimarySettings();
1408 const updateSettings = useUpdatePrimarySettings();
1405 const settings = useScopedSettings();
1406 const updateSettings = useUpdateScopedSettings();
14091407 const defaults = useFontDefaultFamilies();
14101408 return (
14111409 <FontFamilySettingsRow
......@@ -1443,8 +1441,8 @@ function CodeFontRow({
14431441 description?: string;
14441442 preview?: ReactNode;
14451443}) {
1446 const settings = usePrimarySettings();
1447 const updateSettings = useUpdatePrimarySettings();
1444 const settings = useScopedSettings();
1445 const updateSettings = useUpdateScopedSettings();
14481446 const defaults = useFontDefaultFamilies();
14491447 return (
14501448 <FontFamilySettingsRow
......@@ -1476,8 +1474,8 @@ function CodeFontRow({
14761474}
14771475
14781476function TerminalFontRow() {
1479 const settings = usePrimarySettings();
1480 const updateSettings = useUpdatePrimarySettings();
1477 const settings = useScopedSettings();
1478 const updateSettings = useUpdateScopedSettings();
14811479 const defaults = useFontDefaultFamilies();
14821480 return (
14831481 <FontFamilySettingsRow
......@@ -1517,8 +1515,8 @@ function TerminalFontRow() {
15171515}
15181516
15191517function FontSmoothingRow() {
1520 const settings = usePrimarySettings();
1521 const updateSettings = useUpdatePrimarySettings();
1518 const settings = useScopedSettings();
1519 const updateSettings = useUpdateScopedSettings();
15221520 if (!isMacPlatform(navigator.platform)) return null;
15231521 return (
15241522 <SettingsRow
......@@ -1546,8 +1544,8 @@ function FontSmoothingRow() {
15461544}
15471545
15481546function WordWrapRow() {
1549 const settings = usePrimarySettings();
1550 const updateSettings = useUpdatePrimarySettings();
1547 const settings = useScopedSettings();
1548 const updateSettings = useUpdateScopedSettings();
15511549 return (
15521550 <SettingsRow
15531551 {...searchableSetting("word-wrap")}
......@@ -1589,7 +1587,7 @@ function FontSettingsGroup() {
15891587 * under each row show every surface the choice reaches.
15901588 */
15911589function SimpleFontRows() {
1592 const settings = usePrimarySettings();
1590 const settings = useScopedSettings();
15931591 return (
15941592 <>
15951593 <InterfaceFontRow preview={<PromptFontPreview />} />
......@@ -1930,8 +1928,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
19301928 * jump to one of the rows unfolds the section.
19311929 */
19321930function LegacyFeaturesSection() {
1933 const settings = usePrimarySettings();
1934 const updateSettings = useUpdatePrimarySettings();
1931 const settings = useScopedSettings();
1932 const updateSettings = useUpdateScopedSettings();
19351933 const [open, setOpen] = useState(false);
19361934 const searchTargetId = useSettingsSearchTargetId();
19371935 const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features");
......@@ -1969,29 +1967,7 @@ function LegacyFeaturesSection() {
19691967 <Switch
19701968 checked={settings.planModeEnabled}
19711969 onCheckedChange={(checked) => {
1972 const 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 });
1970 updateSettings({ planModeEnabled: Boolean(checked) });
19951971 }}
19961972 aria-label="Plan mode (legacy)"
19971973 />
......@@ -2012,10 +1988,12 @@ function LegacyFeaturesSection() {
20121988 />
20131989 <SettingsRow
20141990 serverScoped
1991 settingKeys={["enableLegacyTokenStreaming"]}
20151992 {...searchableSetting("legacy-token-streaming")}
20161993 description="Stream output token by token. This legacy mode is slower and harder to follow."
20171994 control={
2018 <Switch
1995 <ScopedSwitch
1996 settingKeys={["enableLegacyTokenStreaming"]}
20191997 checked={settings.enableLegacyTokenStreaming}
20201998 onCheckedChange={(checked) => {
20211999 if (!checked) {
......@@ -2058,25 +2036,32 @@ function LegacyFeaturesSection() {
20582036}
20592037
20602038export function GeneralSettingsPanel() {
2061 const settings = usePrimarySettings();
2062 const updateSettings = useUpdatePrimarySettings();
2039 const settings = useScopedSettings();
2040 const updateSettings = useUpdateScopedSettings();
20632041 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;
20652050 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
20662051 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
20672052 readLastEnabledProjectGroupingMode(),
20682053 );
2069 const observability = useAtomValue(primaryServerObservabilityAtom);
2070 const serverProviders = useAtomValue(primaryServerProvidersAtom);
2054 const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
20712055 const supportsAutoSettlement =
2072 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
2073 const diagnosticsDescription = formatDiagnosticsDescription({
2074 localTracingEnabled: observability?.localTracingEnabled ?? false,
2075 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
2076 otlpTracesUrl: observability?.otlpTracesUrl,
2077 otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false,
2078 otlpMetricsUrl: observability?.otlpMetricsUrl,
2079 });
2056 connectedEnvironments.length > 0 &&
2057 connectedEnvironments.every(
2058 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
2059 );
2060 const supportsRestartContinuation =
2061 connectedEnvironments.length > 0 &&
2062 connectedEnvironments.every(
2063 (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true,
2064 );
20802065
20812066 const textGenerationProviders = serverProviders.filter(
20822067 (provider) => provider.supportsTextGeneration !== false,
......@@ -2109,9 +2094,16 @@ export function GeneralSettingsPanel() {
21092094 settings.textGenerationModelSelection ?? null,
21102095 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
21112096 );
2097 const textGenerationModelDisabledReason = useScopedModelDisabledReason(
2098 settings,
2099 textGenerationModelInstanceEntries,
2100 );
21122101 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
21132102 const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
21142103 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
2104 const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]);
2105 const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
2106 const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
21152107 const backgroundActivityDescription =
21162108 backgroundActivityProfileOption === "advanced"
21172109 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${
......@@ -2125,7 +2117,7 @@ export function GeneralSettingsPanel() {
21252117
21262118 return (
21272119 <SettingsPageContainer>
2128 <SharedSettingsMismatchAlert />
2120 <ProjectDefaultsSettings category="general" />
21292121 <SettingsSection id="organization" title="Organization">
21302122 <SettingsRow
21312123 {...searchableSetting("project-grouping")}
......@@ -2167,6 +2159,7 @@ export function GeneralSettingsPanel() {
21672159 <>
21682160 <SettingsRow
21692161 serverScoped
2162 settingKeys={["sidebarAutoSettleOnMerge"]}
21702163 {...searchableSetting("auto-settle-merged-threads")}
21712164 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
21722165 resetAction={
......@@ -2183,7 +2176,8 @@ export function GeneralSettingsPanel() {
21832176 ) : null
21842177 }
21852178 control={
2186 <Switch
2179 <ScopedSwitch
2180 settingKeys={["sidebarAutoSettleOnMerge"]}
21872181 checked={settings.sidebarAutoSettleOnMerge}
21882182 onCheckedChange={(checked) =>
21892183 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
......@@ -2195,6 +2189,7 @@ export function GeneralSettingsPanel() {
21952189
21962190 <SettingsRow
21972191 serverScoped
2192 settingKeys={["sidebarAutoSettleAfterDays"]}
21982193 {...searchableSetting("auto-settle-inactive-threads")}
21992194 description="Sidebar threads with no activity for this long settle automatically."
22002195 resetAction={
......@@ -2212,7 +2207,8 @@ export function GeneralSettingsPanel() {
22122207 ) : null
22132208 }
22142209 control={
2215 <Switch
2210 <ScopedSwitch
2211 settingKeys={["sidebarAutoSettleAfterDays"]}
22162212 checked={settings.sidebarAutoSettleAfterDays !== null}
22172213 onCheckedChange={(checked) =>
22182214 updateSettings({
......@@ -2226,6 +2222,7 @@ export function GeneralSettingsPanel() {
22262222 {settings.sidebarAutoSettleAfterDays !== null ? (
22272223 <SettingsRow
22282224 serverScoped
2225 settingKeys={["sidebarAutoSettleAfterDays"]}
22292226 title={searchableSetting("days-before-auto-settle").title}
22302227 description="Any new activity un-settles a thread automatically."
22312228 control={
......@@ -2423,6 +2420,7 @@ export function GeneralSettingsPanel() {
24232420
24242421 <SettingsRow
24252422 serverScoped
2423 settingKeys={["enableProviderUpdateChecks"]}
24262424 {...searchableSetting("provider-update-checks")}
24272425 description="Check installed provider CLIs for newer available versions."
24282426 resetAction={
......@@ -2439,7 +2437,8 @@ export function GeneralSettingsPanel() {
24392437 ) : null
24402438 }
24412439 control={
2442 <Switch
2440 <ScopedSwitch
2441 settingKeys={["enableProviderUpdateChecks"]}
24432442 checked={settings.enableProviderUpdateChecks}
24442443 onCheckedChange={(checked) =>
24452444 updateSettings({ enableProviderUpdateChecks: Boolean(checked) })
......@@ -2452,10 +2451,17 @@ export function GeneralSettingsPanel() {
24522451 <SettingsRow
24532452 {...searchableSetting("continue-threads-after-server-update")}
24542453 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 }
24562461 resetAction={
2462 supportsRestartContinuation &&
24572463 settings.continueThreadsAfterServerUpdate !==
2458 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
2464 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
24592465 <SettingResetButton
24602466 label="continue threads after restarts"
24612467 onClick={() =>
......@@ -2468,8 +2474,10 @@ export function GeneralSettingsPanel() {
24682474 ) : null
24692475 }
24702476 control={
2471 <Switch
2477 <ScopedSwitch
2478 settingKeys={["continueThreadsAfterServerUpdate"]}
24722479 checked={settings.continueThreadsAfterServerUpdate}
2480 disabled={!supportsRestartContinuation}
24732481 onCheckedChange={(checked) =>
24742482 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
24752483 }
......@@ -2480,6 +2488,7 @@ export function GeneralSettingsPanel() {
24802488
24812489 <SettingsRow
24822490 serverScoped
2491 settingKeys={["backgroundActivity"]}
24832492 id={searchableSetting("background-activity").id}
24842493 title={
24852494 <span className="inline-flex items-center gap-1.5">
......@@ -2502,10 +2511,10 @@ export function GeneralSettingsPanel() {
25022511 control={
25032512 <>
25042513 <Select
2505 value={backgroundActivityProfileOption}
2514 value={mixedBackgroundActivity ? null : backgroundActivityProfileOption}
25062515 onValueChange={(value) => {
25072516 if (value === "advanced") {
2508 setBackgroundActivityDialogOpen(true);
2517 if (isEnvironmentScope) setBackgroundActivityDialogOpen(true);
25092518 return;
25102519 }
25112520 if (
......@@ -2523,7 +2532,9 @@ export function GeneralSettingsPanel() {
25232532 aria-label="Background activity profile"
25242533 >
25252534 <SelectValue>
2526 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]}
2535 {(value: BackgroundActivityProfileOption | null) =>
2536 value === null ? "Mixed" : BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[value]
2537 }
25272538 </SelectValue>
25282539 </SelectTrigger>
25292540 <SelectPopup align="end" alignItemWithTrigger={false}>
......@@ -2536,12 +2547,14 @@ export function GeneralSettingsPanel() {
25362547 <SelectItem hideIndicator value="battery-saver">
25372548 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
25382549 </SelectItem>
2539 <SelectItem hideIndicator value="advanced">
2540 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced}
2550 <SelectItem hideIndicator value="advanced" disabled={!isEnvironmentScope}>
2551 {isEnvironmentScope
2552 ? BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced
2553 : `${BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} (one environment)`}
25412554 </SelectItem>
25422555 </SelectPopup>
25432556 </Select>
2544 {backgroundActivityProfileOption === "advanced" ? (
2557 {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? (
25452558 <Tooltip>
25462559 <TooltipTrigger
25472560 render={
......@@ -2559,7 +2572,7 @@ export function GeneralSettingsPanel() {
25592572 </Tooltip>
25602573 ) : null}
25612574 <BackgroundActivityAdvancedDialog
2562 open={backgroundActivityDialogOpen}
2575 open={backgroundActivityDialogOpen && isEnvironmentScope}
25632576 onOpenChange={setBackgroundActivityDialogOpen}
25642577 />
25652578 </>
......@@ -2568,24 +2581,9 @@ export function GeneralSettingsPanel() {
25682581 </SettingsSection>
25692582
25702583 <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
25872584 <SettingsRow
25882585 serverScoped
2586 settingKeys={["newWorktreesStartFromOrigin"]}
25892587 {...searchableSetting("start-from-origin")}
25902588 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
25912589 resetAction={
......@@ -2603,7 +2601,8 @@ export function GeneralSettingsPanel() {
26032601 ) : null
26042602 }
26052603 control={
2606 <Switch
2604 <ScopedSwitch
2605 settingKeys={["newWorktreesStartFromOrigin"]}
26072606 checked={settings.newWorktreesStartFromOrigin}
26082607 onCheckedChange={(checked) =>
26092608 updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })
......@@ -2614,6 +2613,7 @@ export function GeneralSettingsPanel() {
26142613 />
26152614 <SettingsRow
26162615 serverScoped
2616 settingKeys={["addProjectBaseDirectory"]}
26172617 {...searchableSetting("add-project-starts-in")}
26182618 description='Leave empty to use "~/" when the Add Project browser opens.'
26192619 resetAction={
......@@ -2633,9 +2633,9 @@ export function GeneralSettingsPanel() {
26332633 <DraftInput
26342634 size="sm"
26352635 className="w-full sm:w-72"
2636 value={settings.addProjectBaseDirectory}
2636 value={mixedAddProjectBaseDirectory ? "" : settings.addProjectBaseDirectory}
26372637 onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}
2638 placeholder="~/"
2638 placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"}
26392639 spellCheck={false}
26402640 aria-label="Add project base directory"
26412641 />
......@@ -2768,10 +2768,11 @@ export function GeneralSettingsPanel() {
27682768 <SettingsSection id="text-generation" title="Text generation">
27692769 <SettingsRow
27702770 serverScoped
2771 settingKeys={["textGenerationModelSelection"]}
27712772 {...searchableSetting("text-generation-model")}
27722773 description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it."
27732774 resetAction={
2774 isTextGenerationModelDirty ? (
2775 hasServerTargets && isTextGenerationModelDirty ? (
27752776 <SettingResetButton
27762777 label="text generation model"
27772778 onClick={() =>
......@@ -2784,7 +2785,11 @@ export function GeneralSettingsPanel() {
27842785 ) : null
27852786 }
27862787 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 ? (
27882793 <span className="text-sm text-muted-foreground">
27892794 No text generation providers available.
27902795 </span>
......@@ -2798,6 +2803,8 @@ export function GeneralSettingsPanel() {
27982803 modelOptionsByInstance={textGenerationModelOptionsByInstance}
27992804 triggerVariant="outline"
28002805 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2806 {...(mixedTextGenerationModel ? { triggerLabel: "Mixed" } : {})}
2807 getModelDisabledReason={textGenerationModelDisabledReason}
28012808 {...(environmentId
28022809 ? {
28032810 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
......@@ -2809,6 +2816,15 @@ export function GeneralSettingsPanel() {
28092816 }
28102817 : {})}
28112818 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 }
28122828 updateSettings({
28132829 textGenerationModelSelection: resolveAppModelSelectionState(
28142830 {
......@@ -2870,11 +2886,23 @@ export function GeneralSettingsPanel() {
28702886 description="Current version of the application."
28712887 />
28722888 )}
2889 </SettingsSection>
2890 <SettingsSection title="Diagnostics">
28732891 <SettingsRow
28742892 {...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 }
28762898 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 >
28782906 View diagnostics
28792907 </Button>
28802908 }
......@@ -2887,25 +2915,31 @@ export function GeneralSettingsPanel() {
28872915}
28882916
28892917export function ArchivedThreadsPanel() {
2890 const projects = useProjects();
2918 const { scope } = useSettingsScope();
28912919 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
2892 const environmentIds = useMemo(
2893 () => [...new Set(projects.map((project) => project.environmentId))],
2894 [projects],
2895 );
28962920 const {
28972921 snapshots: archivedSnapshots,
28982922 error: archiveError,
28992923 isLoading: isLoadingArchive,
29002924 refresh: refreshArchivedThreads,
2901 } = useArchivedThreadSnapshots(environmentIds);
2925 } = useArchivedThreadSnapshots(scope.environmentIds);
29022926
29032927 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;
29042932 const projectsByEnvironmentAndId = new Map(
29052933 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
2906 snapshot.projects.map(
2907 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
2908 ),
2934 snapshot.projects
2935 .filter(
2936 (project) =>
2937 selectedProjectKeys === null ||
2938 selectedProjectKeys.has(`${environmentId}:${project.id}`),
2939 )
2940 .map(
2941 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
2942 ),
29092943 ),
29102944 );
29112945 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
......@@ -2939,7 +2973,7 @@ export function ArchivedThreadsPanel() {
29392973 }
29402974 }
29412975 return groups;
2942 }, [archivedSnapshots]);
2976 }, [archivedSnapshots, scope]);
29432977
29442978 const handleArchivedThreadContextMenu = useCallback(
29452979 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
......@@ -3021,7 +3055,7 @@ export function ArchivedThreadsPanel() {
30213055 ) : (
30223056 archivedGroups.map(({ project, threads: projectThreads }, index) => (
30233057 <SettingsSection
3024 key={project.id}
3058 key={`${project.environmentId}:${project.id}`}
30253059 id={index === 0 ? searchableSetting("archive").id : undefined}
30263060 title={project.title}
30273061 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";
4141import { scrollToSettingsTarget } from "./settingsLayout";
4242import {
4343 searchSettings,
44 isSettingsOverviewVisible,
4445 SETTINGS_SECTION_LABELS,
4546 type SettingsPath,
4647 type SettingsSearchItem,
4748} from "./settingsSearch";
4849import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
50import { validateSettingsScopeSearch } from "./settingsScope";
4951
5052const SnapShotIcon = createLucideIcon("snap-shot", [
5153 [
......@@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
103105export function SettingsSidebarNav({ pathname }: { pathname: string }) {
104106 const navigate = useNavigate();
105107 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 );
106113 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
107114 const searchInputRef = useRef<HTMLInputElement>(null);
108115 const [query, setQuery] = useState("");
......@@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
181188 setOpenMobile(false);
182189 }
183190 const targetId = item.targetId ?? item.id;
184 if (
185 item.to !== "/settings/projects" &&
186 pathname === item.to &&
187 currentHash.replace(/^#/, "") === targetId
188 ) {
191 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
189192 scrollToSettingsTarget(targetId);
190193 return;
191194 }
192195 void navigate({
193196 to: item.to,
194 search: (previous) =>
195 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
196197 hash: targetId,
197198 replace: true,
198199 hashScrollIntoView: false,
......@@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
319320 </SidebarMenu>
320321 ) : (
321322 <SidebarMenu className="ps-px">
322 {SETTINGS_NAV_ITEMS.map((item) => {
323 {navItems.map((item) => {
323324 const Icon = item.icon;
324325 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
325326 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 () => {
2626});
2727vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
2828vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
29vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
2930const bridge = vi.hoisted(() => ({
3031 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),
3132 setSnapShotShortcutSuppressed: vi.fn(),
apps/web/src/components/settings/SourceControlSettings.tsx+31-23
......@@ -18,10 +18,10 @@ import {
1818 resolveServerBackgroundActivitySettings,
1919} from "@t3tools/shared/backgroundActivitySettings";
2020
21import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
22import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert";
21import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
22import { useSettingsScope } from "./SettingsScopeContext";
23import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
2324import { cn } from "../../lib/utils";
24import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
2525import { useEnvironmentQuery } from "../../state/query";
2626import { sourceControlEnvironment } from "../../state/sourceControl";
2727import { Badge } from "../ui/badge";
......@@ -343,8 +343,8 @@ function DiscoveryItemRow({
343343}
344344
345345function GitFetchIntervalSettings() {
346 const settings = usePrimarySettings();
347 const updateSettings = useUpdatePrimarySettings();
346 const settings = useScopedSettings();
347 const updateSettings = useUpdateScopedSettings();
348348 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
349349 const automaticGitFetchIntervalSeconds = durationToSeconds(
350350 resolvedBackgroundActivity.automaticGitFetchInterval,
......@@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({
498498}
499499
500500export function SourceControlSettingsPanel() {
501 const { environments } = useEnvironments();
502 const primaryEnvironment = usePrimaryEnvironment();
503 const fallbackEnvironment =
504 environments.find((environment) => environment.connection.phase === "connected") ??
505 environments[0] ??
506 null;
501 const { scope, environment, connectedEnvironments } = useSettingsScope();
502 // Discovery scans one machine's tools, so it shows the representative
503 // environment (named in the section title when several are selected);
504 // the settings rows above it fan out like everywhere else.
507505 const environmentId =
508 primaryEnvironment?.environmentId ?? fallbackEnvironment?.environmentId ?? null;
509 const isPrimaryEnvironment = environmentId === primaryEnvironment?.environmentId;
506 environment?.connection.phase === "connected" ? environment.environmentId : null;
507 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
508 const environmentSuffix = aggregate && environment ? ` · ${environment.label}` : "";
510509 const discovery = useEnvironmentQuery(
511510 environmentId === null
512511 ? null
......@@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() {
543542
544543 return (
545544 <SettingsPageContainer>
546 <SharedSettingsMismatchAlert />
547 {isInitialScanPending ? (
545 <ProjectDefaultsSettings category="source-control" />
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 ? (
548553 <>
549 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />
554 <SourceControlSectionSkeleton
555 title={`Version Control${environmentSuffix}`}
556 headerAction={scanButton}
557 />
550558 <SourceControlSectionSkeleton title="Source Control Providers" />
551559 </>
552560 ) : hasDiscoveryItems ? (
......@@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() {
554562 {hasVersionControlSystems ? (
555563 <SettingsSection
556564 id={searchableSetting("source-control").id}
557 title="Version Control"
565 title={`Version Control${environmentSuffix}`}
558566 headerAction={scanButton}
559567 >
560568 {result.versionControlSystems.map((item) => (
561569 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>
562 {item.kind === "git" && isPrimaryEnvironment ? (
563 <GitFetchIntervalSettings />
564 ) : undefined}
570 {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined}
565571 </DiscoveryItemRow>
566572 ))}
567573 </SettingsSection>
......@@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() {
570576 {result.sourceControlProviders.length > 0 ? (
571577 <SettingsSection
572578 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 }
574584 headerAction={hasVersionControlSystems ? null : scanButton}
575585 >
576586 {result.sourceControlProviders.map((item) => (
......@@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() {
587597 />
588598 )}
589599
590 {/* Its rows are serverScoped: without a primary they render inert with
591 an explanation, which beats disappearing. */}
592600 <SourceControlWritingSettingsSection />
593601 </SettingsPageContainer>
594602 );
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 @@
1import { useAtomValue } from "@effect/atom-react";
21import { useNavigate } from "@tanstack/react-router";
3import { useRef } from "react";
4import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts";
2import { useRef, useState } from "react";
3import type {
4 ProviderInstanceId,
5 ServerSettings,
6 SourceControlWritingStyleMode,
7} from "@t3tools/contracts";
58import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
69import { createModelSelection } from "@t3tools/shared/model";
710import { 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";
1019import {
1120 applyProviderInstanceSettings,
1221 deriveProviderInstanceEntries,
......@@ -16,12 +25,13 @@ import {
1625 getCustomModelOptionsByInstance,
1726 resolveAppModelSelectionState,
1827} from "../../modelSelection";
19import { primaryServerProvidersAtom } from "../../state/server";
20import { usePrimaryEnvironmentId } from "../../state/environments";
28import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
2129import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2230import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2331import { Switch } from "../ui/switch";
2432import { Textarea } from "../ui/textarea";
33import { toastManager } from "../ui/toast";
34import { Button } from "../ui/button";
2535import {
2636 SETTINGS_PICKER_TRIGGER_CLASSNAME,
2737 SettingResetButton,
......@@ -48,16 +58,41 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr
4858 };
4959
5060export function SourceControlWritingSettingsSection() {
51 const settings = usePrimarySettings();
52 const updateSettings = useUpdatePrimarySettings();
61 const settings = useScopedSettings();
62 const updateSettings = useUpdateScopedSettings();
5363 const navigate = useNavigate();
54 const environmentId = usePrimaryEnvironmentId();
55 const serverProviders = useAtomValue(primaryServerProvidersAtom);
64 const { environment, connectedEnvironments, targets } = useSettingsScope();
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"]);
5687 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
88 const [editingAllInstructions, setEditingAllInstructions] = useState(false);
89 const [allInstructions, setAllInstructions] = useState<string | null>(null);
5790 const style = settings.sourceControlWritingStyle;
5891 const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
5992 const isSourceControlWritingStyleDirty =
60 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
93 writingStyleMixed ||
94 style.mode !== defaults.mode ||
95 style.customInstructions !== defaults.customInstructions;
6196
6297 const textGenerationProviders = serverProviders.filter(
6398 (provider) => provider.supportsTextGeneration !== false,
......@@ -87,11 +122,14 @@ export function SourceControlWritingSettingsSection() {
87122 activeSelection.instanceId,
88123 activeSelection.model,
89124 );
125 const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
90126
91127 return (
92128 <SettingsSection id="source-control-text-generation" title="Text generation">
93129 <SettingsRow
94130 serverScoped
131 settingKeys={["sourceControlWritingStyle"]}
132 mixed={writingStyleMixed}
95133 {...searchableSetting("source-control-writing-style")}
96134 description={MODE_OPTIONS[style.mode].description}
97135 resetAction={
......@@ -111,7 +149,7 @@ export function SourceControlWritingSettingsSection() {
111149 }
112150 control={
113151 <Select
114 value={style.mode}
152 value={modeMixed ? null : style.mode}
115153 onValueChange={(value) => {
116154 const customInstructions = customInstructionsRef.current?.value.trim();
117155 updateSettings({
......@@ -127,7 +165,11 @@ export function SourceControlWritingSettingsSection() {
127165 className="w-full sm:w-56"
128166 aria-label="Source control writing style"
129167 >
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>
131173 </SelectTrigger>
132174 <SelectPopup align="end" alignItemWithTrigger={false}>
133175 {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => (
......@@ -139,7 +181,49 @@ export function SourceControlWritingSettingsSection() {
139181 </Select>
140182 }
141183 >
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" ? (
143227 <div className="mt-3 max-w-2xl pb-3.5">
144228 <Textarea
145229 key={style.customInstructions}
......@@ -161,9 +245,12 @@ export function SourceControlWritingSettingsSection() {
161245
162246 <SettingsRow
163247 serverScoped
248 settingKeys={["sourceControlWritingStyle"]}
249 mixed={templatesMixed}
164250 {...searchableSetting("follow-change-request-templates")}
165251 description="Use the repository's template for change request descriptions when available."
166252 resetAction={
253 templatesMixed ||
167254 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
168255 <SettingResetButton
169256 label="change request templates"
......@@ -179,7 +266,8 @@ export function SourceControlWritingSettingsSection() {
179266 }
180267 control={
181268 <Switch
182 checked={style.followChangeRequestTemplates}
269 mixed={templatesMixed}
270 checked={templatesMixed ? false : style.followChangeRequestTemplates}
183271 onCheckedChange={(checked) =>
184272 updateSettings({
185273 sourceControlWritingStyle: {
......@@ -194,59 +282,77 @@ export function SourceControlWritingSettingsSection() {
194282
195283 <SettingsRow
196284 serverScoped
285 settingKeys={["sourceControlWriterModelSelection"]}
197286 {...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."
199288 control={
200 <div className="flex flex-wrap items-center justify-end gap-2">
201 {usesDedicatedModel && !canEnableDedicatedModel ? (
202 <span className="text-sm text-muted-foreground">
203 No text generation providers available.
204 </span>
205 ) : null}
206 {usesDedicatedModel && canEnableDedicatedModel ? (
207 <ProviderModelPicker
208 activeInstanceId={activeSelection.instanceId}
209 model={activeSelection.model}
210 lockedProvider={null}
211 instanceEntries={instanceEntries}
212 modelOptionsByInstance={modelOptionsByInstance}
213 triggerVariant="outline"
214 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
215 triggerAriaLabel="Source control writer model"
216 {...(environmentId
217 ? {
218 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
219 void navigate({
220 to: "/settings/providers",
221 search: { environmentId, instanceId },
222 });
223 },
289 !hasServerTargets ? (
290 <span className="text-sm text-muted-foreground">
291 Connect an environment to choose its source control writer model.
292 </span>
293 ) : (
294 <div className="flex flex-wrap items-center justify-end gap-2">
295 {usesDedicatedModel && !canEnableDedicatedModel ? (
296 <span className="text-sm text-muted-foreground">
297 No text generation providers available.
298 </span>
299 ) : null}
300 {usesDedicatedModel && canEnableDedicatedModel ? (
301 <ProviderModelPicker
302 activeInstanceId={activeSelection.instanceId}
303 model={activeSelection.model}
304 lockedProvider={null}
305 instanceEntries={instanceEntries}
306 modelOptionsByInstance={modelOptionsByInstance}
307 triggerVariant="outline"
308 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
309 triggerAriaLabel="Source control writer model"
310 {...(mixedWriterModel ? { triggerLabel: "Mixed" } : {})}
311 {...(environmentId
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;
224331 }
225 : {})}
226 onInstanceModelChange={(instanceId, model) => {
332 updateSettings({
333 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
334 });
335 }}
336 />
337 ) : null}
338 <Switch
339 checked={usesDedicatedModel}
340 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
341 onCheckedChange={(checked) =>
227342 updateSettings({
228 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
229 });
230 }}
343 sourceControlWriterModelSelection: checked
344 ? createModelSelection(
345 defaultModelSelection.instanceId,
346 defaultModelSelection.model,
347 defaultModelSelection.options,
348 )
349 : null,
350 })
351 }
352 aria-label="Use a separate source control writer model"
231353 />
232 ) : null}
233 <Switch
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>
354 </div>
355 )
250356 }
251357 />
252358 </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 @@
11import { InfoIcon, Undo2Icon } from "lucide-react";
2import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
3import * as Equal from "effect/Equal";
24import { useLocation, useNavigate } from "@tanstack/react-router";
35import {
46 createContext,
......@@ -19,6 +21,21 @@ import { cn } from "../../lib/utils";
1921import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
2022import { Button } from "../ui/button";
2123import { 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
2340declare module "@tanstack/react-router" {
2441 interface HistoryState {
......@@ -253,8 +270,11 @@ export function SettingsRow({
253270 description,
254271 status,
255272 resetAction,
273 onResetOverride,
256274 control,
257275 serverScoped = false,
276 settingKeys = EMPTY_SETTING_KEYS,
277 mixed: mixedOverride,
258278 children,
259279 className,
260280 ...rowProps
......@@ -263,37 +283,152 @@ export function SettingsRow({
263283 description?: ReactNode;
264284 status?: ReactNode;
265285 resetAction?: ReactNode;
286 /** Replaces the default override clear for rows with side effects beyond the settings key. */
287 onResetOverride?: () => void;
266288 control?: ReactNode;
267289 serverScoped?: boolean;
290 settingKeys?: readonly (keyof ServerSettings)[];
291 mixed?: boolean;
268292 children?: ReactNode;
269293}) {
270294 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
271295 const primarySettingsAvailable = usePrimarySettingsAvailable();
272 const unavailable = serverScoped && !primarySettingsAvailable;
273 const renderedReset = unavailable ? null : resetAction;
296 const context = useOptionalSettingsScope();
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.
274387 const renderedControl =
275 unavailable && control ? (
276 <Tooltip>
277 <TooltipTrigger
278 render={
279 // Focusable so keyboard users can still reach the explanation.
280 <span
281 tabIndex={0}
282 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
283 />
284 }
285 >
286 <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto">
287 {control}
288 </div>
289 </TooltipTrigger>
290 <TooltipPopup side="top" className="max-w-72">
291 {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}
292 </TooltipPopup>
293 </Tooltip>
294 ) : (
295 control
388 unavailable && control
389 ? inertControl(
390 context
391 ? "Reconnect the selected environment to change this setting."
392 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
393 )
394 : environmentWide && control
395 ? inertControl("Environment-wide setting. Select an environment to change it.")
396 : control;
397 // Server rows get an indicator beside the title that opens the resolution
398 // chain per target at every scope; client rows keep a plain status only.
399 const customized =
400 context !== null &&
401 settingKeys.some((key) =>
402 context.targets.some((candidate) => {
403 const environmentSettings = environmentSettingsById.get(candidate.environmentId);
404 return (
405 environmentSettings !== undefined &&
406 !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key])
407 );
408 }),
296409 );
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
298433 return (
299434 <div
......@@ -311,6 +446,11 @@ export function SettingsRow({
311446 <div className="min-w-0 flex-1 space-y-1">
312447 <div className="flex min-h-5 items-center gap-1.5">
313448 <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}
314454 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
315455 {renderedReset}
316456 </span>
......@@ -320,7 +460,9 @@ export function SettingsRow({
320460 {description}
321461 </p>
322462 ) : 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}
324466 </div>
325467 {renderedControl ? (
326468 <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(
6060const groups = [group("t3code", [first, second, third]), group("other", [other])];
6161
6262describe("settings scope search", () => {
63 it.each(["device", "all"] as const)(
64 "clears narrower targets for an explicit %s selection",
65 (scope) => {
66 expect(
67 validateSettingsScopeSearch({
68 scope,
69 project: "t3code",
70 machine: laptopId,
71 checkout: first.physicalProjectKey,
72 }),
73 ).toEqual({ scope });
74 },
75 );
63 it("ignores the retired scope key from older links", () => {
64 expect(validateSettingsScopeSearch({ scope: "device", project: "t3code" })).toEqual({
65 project: "t3code",
66 });
67 expect(validateSettingsScopeSearch({ scope: "all" })).toEqual({});
68 });
7669
7770 it("retains legacy project and machine links without inventing an explicit broad scope", () => {
7871 expect(
......@@ -96,15 +89,11 @@ describe("settings scope search", () => {
9689});
9790
9891describe("settings scope resolution", () => {
99 it("distinguishes this device from the default aggregate target", () => {
92 it("defaults to every environment with no project", () => {
10093 expect(resolveSettingsScope({}, groups, environments)).toMatchObject({
10194 kind: "all",
102 environmentIds: [laptopId, serverId],
103 });
104 expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({
105 kind: "device",
10695 members: [],
107 environmentIds: [],
96 environmentIds: [laptopId, serverId],
10897 });
10998 });
11099
apps/web/src/components/settings/settingsScope.ts+8-15
......@@ -6,8 +6,13 @@ import type {
66} from "../../sidebarProjectGrouping";
77import 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 */
915export interface SettingsScopeSearch {
10 scope?: "device" | "all" | undefined;
1116 project?: string | undefined;
1217 machine?: string | undefined;
1318 checkout?: string | undefined;
......@@ -21,7 +26,7 @@ type ScopeTargets = {
2126
2227export type ResolvedSettingsScope = ScopeTargets &
2328 (
24 | { kind: "device" | "all" }
29 | { kind: "all" }
2530 | { kind: "environment"; environmentId: EnvironmentId }
2631 | {
2732 kind: "project";
......@@ -41,9 +46,8 @@ export type ResolvedSettingsScope = ScopeTargets &
4146 }
4247 );
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". */
4550export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch {
46 if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope };
4751 const stringValue = (value: unknown) =>
4852 typeof value === "string" && value.trim().length > 0 ? value : undefined;
4953 const project = stringValue(raw.project);
......@@ -74,17 +78,6 @@ export function resolveSettingsScope(
7478 environmentIds: [],
7579 });
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 }
8881 if (search.checkout && !search.project) {
8982 return unavailable("project-required", "Select a project to choose one of its checkouts.");
9083 }
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 @@
11import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
23
34import {
45 filterAvailableSettingsSearchItems,
6 getSettingsSearchTargetScope,
7 getThreadAutoSettlementSearchAvailability,
8 isSettingsOverviewVisible,
9 isSettingsSearchScopeAvailable,
510 searchableSetting,
611 searchSettings,
712 SETTINGS_SEARCH_ITEMS,
......@@ -143,7 +148,7 @@ describe("searchSettings", () => {
143148 it("hides settings whose controls are unavailable", () => {
144149 const available = filterAvailableSettingsSearchItems({
145150 hasCloudPublicConfig: false,
146 hasPrimaryEnvironment: false,
151 hasEnvironment: false,
147152 hasProviderSettingsEnvironment: false,
148153 canManageLocalBackend: false,
149154 isWslSettingsRowVisible: false,
......@@ -171,7 +176,7 @@ describe("searchSettings", () => {
171176 it("shows automatic settlement settings when the server supports them", () => {
172177 const available = filterAvailableSettingsSearchItems({
173178 hasCloudPublicConfig: false,
174 hasPrimaryEnvironment: false,
179 hasEnvironment: false,
175180 hasProviderSettingsEnvironment: false,
176181 canManageLocalBackend: false,
177182 isWslSettingsRowVisible: false,
......@@ -256,4 +261,194 @@ describe("searchSettings", () => {
256261 targetId: "browser-profiles",
257262 });
258263 });
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 });
259454});
apps/web/src/components/settings/settingsSearch.ts+187-14
......@@ -1,5 +1,12 @@
11import { isElectron } from "~/env";
22import { 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
411export type SettingsPath =
512 | "/settings/projects"
......@@ -13,6 +20,19 @@ export type SettingsPath =
1320 | "/settings/connections"
1421 | "/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
1636export interface SettingsSearchItem {
1737 readonly id: string;
1838 readonly title: string;
......@@ -20,6 +40,7 @@ export interface SettingsSearchItem {
2040 readonly targetId?: string;
2141 /** Descriptions, option labels, and aliases people may remember instead of the title. */
2242 readonly searchTerms?: ReadonlyArray<string>;
43 readonly scope?: SettingsSearchScope;
2344 // Its row only renders in the desktop app, so a browser result would land on
2445 // an anchor that isn't there.
2546 readonly desktopOnly?: boolean;
......@@ -28,7 +49,7 @@ export interface SettingsSearchItem {
2849 // not expose a result that points to a missing anchor.
2950 readonly windowsOnly?: boolean;
3051 readonly cloudOnly?: boolean;
31 readonly primaryOnly?: boolean;
52 readonly environmentOnly?: boolean;
3253 readonly providerSettingsOnly?: boolean;
3354 readonly localBackendManagementOnly?: boolean;
3455 readonly wslAvailableOnly?: boolean;
......@@ -37,7 +58,7 @@ export interface SettingsSearchItem {
3758
3859export interface SettingsSearchAvailability {
3960 readonly hasCloudPublicConfig: boolean;
40 readonly hasPrimaryEnvironment: boolean;
61 readonly hasEnvironment: boolean;
4162 readonly hasProviderSettingsEnvironment: boolean;
4263 readonly canManageLocalBackend: boolean;
4364 readonly isWslSettingsRowVisible: boolean;
......@@ -49,9 +70,9 @@ export interface SettingsSearchAvailability {
4970 * subtitles both render from this record, so each label exists once.
5071 */
5172export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
73 "/settings/projects": "Project",
5274 "/settings/general": "General",
5375 "/settings/appearance": "Appearance",
54 "/settings/projects": "Projects",
5576 "/settings/keybindings": "Keybindings",
5677 "/settings/snap-shot": "SnapShots",
5778 "/settings/providers": "Providers",
......@@ -70,10 +91,22 @@ export const SETTINGS_SEARCH_ITEMS = [
7091 {
7192 id: "project-defaults",
7293 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",
73101 to: "/settings/projects",
74 searchTerms: [
75 "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts",
76 ],
102 searchTerms: ["name icon emoji image checkout remove delete"],
103 },
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"],
77110 },
78111 {
79112 id: "color-scheme",
......@@ -174,6 +207,7 @@ export const SETTINGS_SEARCH_ITEMS = [
174207 to: "/settings/general",
175208 searchTerms: ["sidebar inactivity days no activity automatically"],
176209 requiresThreadAutoSettlement: true,
210 scope: "project-defaults",
177211 },
178212 {
179213 id: "auto-settle-merged-threads",
......@@ -181,6 +215,7 @@ export const SETTINGS_SEARCH_ITEMS = [
181215 to: "/settings/general",
182216 searchTerms: ["pull request merge closed automatically sidebar"],
183217 requiresThreadAutoSettlement: true,
218 scope: "project-defaults",
184219 },
185220 {
186221 id: "days-before-auto-settle",
......@@ -189,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [
189224 targetId: "auto-settle-inactive-threads",
190225 searchTerms: ["thread timeout activity sidebar"],
191226 requiresThreadAutoSettlement: true,
227 scope: "project-defaults",
192228 },
193229 {
194230 id: "time-format",
......@@ -231,11 +267,13 @@ export const SETTINGS_SEARCH_ITEMS = [
231267 title: "Provider update checks",
232268 to: "/settings/general",
233269 searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
270 scope: "environment-defaults",
234271 },
235272 {
236273 id: "continue-threads-after-server-update",
237274 title: "Continue threads after restarts",
238275 to: "/settings/general",
276 scope: "project-defaults",
239277 searchTerms: [
240278 "resume running active interrupted work restart reboot machine crash desktop update automatically",
241279 ],
......@@ -244,6 +282,7 @@ export const SETTINGS_SEARCH_ITEMS = [
244282 id: "background-activity",
245283 title: "Background activity",
246284 to: "/settings/general",
285 scope: "environment-defaults",
247286 searchTerms: [
248287 "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy",
249288 ],
......@@ -251,19 +290,22 @@ export const SETTINGS_SEARCH_ITEMS = [
251290 {
252291 id: "new-threads",
253292 title: "New threads",
254 to: "/settings/projects",
293 to: "/settings/general",
294 scope: "project-defaults",
255295 searchTerms: ["default workspace mode draft local worktree"],
256296 },
257297 {
258298 id: "start-from-origin",
259299 title: "Start from origin",
260300 to: "/settings/general",
301 scope: "project-defaults",
261302 searchTerms: ["new worktrees latest matching remote branch local"],
262303 },
263304 {
264305 id: "add-project-starts-in",
265306 title: "Add project starts in",
266307 to: "/settings/general",
308 scope: "environment-defaults",
267309 searchTerms: ["base directory folder browser path home"],
268310 },
269311 {
......@@ -295,6 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [
295337 id: "text-generation-model",
296338 title: "Text generation model",
297339 to: "/settings/general",
340 scope: "project-defaults",
298341 searchTerms: ["generated thread titles source control content default provider"],
299342 },
300343 {
......@@ -319,6 +362,7 @@ export const SETTINGS_SEARCH_ITEMS = [
319362 id: "legacy-token-streaming",
320363 title: "Stream token by token (legacy)",
321364 to: "/settings/general",
365 scope: "project-defaults",
322366 searchTerms: ["response output old compatibility"],
323367 },
324368 {
......@@ -399,8 +443,9 @@ export const SETTINGS_SEARCH_ITEMS = [
399443 {
400444 id: "agent-browser-access",
401445 title: "Agent browser access",
402 to: "/settings/projects",
403 searchTerms: ["allow open drive preview tools sessions"],
446 to: "/settings/integrations",
447 scope: "project-defaults",
448 searchTerms: ["allow disable enable open drive preview tools sessions project override"],
404449 },
405450 {
406451 id: "device-hosts",
......@@ -476,10 +521,25 @@ export const SETTINGS_SEARCH_ITEMS = [
476521 to: "/settings/integrations",
477522 searchTerms: ["agent opens browser pop into view hide"],
478523 },
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 },
479538 {
480539 id: "source-control",
481540 title: "Source control",
482541 to: "/settings/source-control",
542 scope: "environment-defaults",
483543 searchTerms: [
484544 "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
485545 ],
......@@ -491,7 +551,8 @@ export const SETTINGS_SEARCH_ITEMS = [
491551 searchTerms: [
492552 "automatic remote branch refresh background credentials security keys seconds off",
493553 ],
494 primaryOnly: true,
554 environmentOnly: true,
555 scope: "environment-defaults",
495556 },
496557 {
497558 id: "source-control-writing-style",
......@@ -500,14 +561,14 @@ export const SETTINGS_SEARCH_ITEMS = [
500561 searchTerms: [
501562 "repository conventions conventional commits custom instructions change descriptions request titles",
502563 ],
503 primaryOnly: true,
564 environmentOnly: true,
504565 },
505566 {
506567 id: "follow-change-request-templates",
507568 title: "Follow change request templates",
508569 to: "/settings/source-control",
509570 searchTerms: ["repository pr pull request description structure"],
510 primaryOnly: true,
571 environmentOnly: true,
511572 },
512573 {
513574 id: "source-control-writer-model",
......@@ -516,7 +577,14 @@ export const SETTINGS_SEARCH_ITEMS = [
516577 searchTerms: [
517578 "override generated commit change request pr titles descriptions branch bookmark",
518579 ],
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"],
520588 },
521589 {
522590 id: "environment-icon",
......@@ -606,6 +674,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
606674
607675const 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
609782/**
610783 * `id` and `title` props for the element a search item anchors to. Panels
611784 * spread (or pick from) this instead of restating the strings, so the catalog
......@@ -626,7 +799,7 @@ export function filterAvailableSettingsSearchItems(
626799 return items.filter(
627800 (item) =>
628801 (!item.cloudOnly || availability.hasCloudPublicConfig) &&
629 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
802 (!item.environmentOnly || availability.hasEnvironment) &&
630803 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
631804 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
632805 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+8-16
......@@ -1,23 +1,22 @@
11import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
32import { AuthAccessWriteScope } from "@t3tools/contracts";
43
54import { hasCloudPublicConfig } from "~/cloud/publicConfig";
65import { isElectron } from "~/env";
76import { desktopWslStateAtom } from "~/state/desktopWslState";
8import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
7import { useEnvironments } from "~/state/environments";
98import { useEnvironmentQuery } from "~/state/query";
109import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
1210import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1311import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
14import { filterAvailableSettingsSearchItems } from "./settingsSearch";
12import {
13 filterAvailableSettingsSearchItems,
14 getThreadAutoSettlementSearchAvailability,
15} from "./settingsSearch";
1516
1617export function useAvailableSettingsSearchItems() {
17 const primaryEnvironmentId = usePrimaryEnvironmentId();
1818 const { environments } = useEnvironments();
1919 const primarySessionState = usePrimarySessionState();
20 const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
2120 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
2221 const canManageLocalBackend =
2322 isElectron ||
......@@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() {
2928 () =>
3029 filterAvailableSettingsSearchItems({
3130 hasCloudPublicConfig: hasCloudPublicConfig(),
32 hasPrimaryEnvironment: primaryEnvironmentId !== null,
31 hasEnvironment: environments.some((environment) => environment.serverConfig !== null),
3332 hasProviderSettingsEnvironment: environments.some((environment) =>
3433 isProviderSettingsEnvironmentAvailable({
3534 connectionPhase: environment.connection.phase,
......@@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() {
4241 error: desktopWsl.error,
4342 }),
4443 hasThreadAutoSettlement:
45 primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
44 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
4645 }),
47 [
48 canManageLocalBackend,
49 desktopWsl.data,
50 desktopWsl.error,
51 environments,
52 primaryEnvironmentId,
53 primaryServerConfig,
54 ],
46 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments],
5547 );
5648}
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(
3333 micro:
3434 "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",
3535 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",
3638 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",
3739 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",
3840 },
apps/web/src/components/ui/switch.tsx+12-2
......@@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
44
55import { 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 */
712function Switch({
813 className,
914 size = "default",
15 mixed = false,
1016 ...props
11}: SwitchPrimitive.Root.Props & { size?: "default" | "sm" }) {
17}: SwitchPrimitive.Root.Props & { size?: "default" | "sm"; mixed?: boolean }) {
1218 return (
1319 <SwitchPrimitive.Root
1420 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",
1622 size === "sm"
1723 ? "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]"
1824 : "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]",
......@@ -20,11 +26,15 @@ function Switch({
2026 )}
2127 data-size={size}
2228 data-slot="switch"
29 data-mixed={mixed ? "" : undefined}
30 aria-checked={mixed ? "mixed" : undefined}
2331 {...props}
2432 >
2533 <SwitchPrimitive.Thumb
2634 className={cn(
2735 "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)]",
2838 )}
2939 data-slot="switch-thumb"
3040 />
apps/web/src/hooks/useHandleNewThread.test.ts+104-21
......@@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vite-plus/test";
33const testState = vi.hoisted(() => {
44 let completeProjectFileRead: (value: null) => void = () => undefined;
55 let projectFileRead = Promise.resolve<null>(null);
6 let targetSettings = {
7 defaultThreadEnvMode: "local" as "local" | "worktree",
8 newWorktreesStartFromOrigin: false,
9 defaultModelSelection: null,
10 };
611 let storedDraft: {
712 readonly draftId: string;
813 readonly environmentId: string;
......@@ -35,10 +40,25 @@ const testState = vi.hoisted(() => {
3540 get projectFileRead() {
3641 return projectFileRead;
3742 },
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 ) {
3953 storedDraft = nextStoredDraft;
54 targetSettings = {
55 defaultThreadEnvMode: workspaceDefaults.envMode,
56 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
57 defaultModelSelection: null,
58 };
4059 router.state.location.href = "/";
4160 router.navigate.mockClear();
61 draftStore.setDraftThreadContext.mockClear();
4262 draftStore.setLogicalProjectDraftThreadId.mockClear();
4363 projectFileRead = new Promise<null>((resolve) => {
4464 completeProjectFileRead = resolve;
......@@ -51,18 +71,18 @@ const testState = vi.hoisted(() => {
5171vi.mock("@effect/atom-react", () => ({
5272 useAtomValue: (atom: unknown) =>
5373 atom === "primary-settings"
54 ? { newWorktreesStartFromOrigin: false }
74 ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin }
5575 : new Map([
5676 [
57 "environment-ssh",
77 "environment-primary",
5878 {
5979 settings: {
60 defaultThreadEnvMode: "local",
61 newWorktreesStartFromOrigin: false,
62 defaultModelSelection: null,
80 ...testState.targetSettings,
81 newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin,
6382 },
6483 },
6584 ],
85 ["environment-ssh", { settings: testState.targetSettings }],
6686 ]),
6787}));
6888vi.mock("@t3tools/client-runtime/environment", () => ({
......@@ -74,6 +94,15 @@ vi.mock("@t3tools/contracts", () => ({
7494 DEFAULT_RUNTIME_MODE: "default",
7595 DEFAULT_SERVER_SETTINGS: {},
7696}));
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}));
77106vi.mock("@t3tools/shared/threadEnvMode", () => ({
78107 resolveDefaultThreadEnvMode: (input: {
79108 readonly projectFile: "local" | "worktree" | null;
......@@ -99,9 +128,9 @@ vi.mock("../composerDraftStore", () => {
99128 useComposerDraftStore,
100129 };
101130});
102vi.mock("../lib/chatThreadActions", () => ({
131vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
132 ...(await importOriginal<typeof import("../lib/chatThreadActions")>()),
103133 hasExplicitComposerModelSelection: () => false,
104 resolveNewDraftStartFromOrigin: () => false,
105134 resolveNewThreadModelSelectionOverride: () => null,
106135}));
107136vi.mock("../lib/t3ProjectFileDefaults", () => ({
......@@ -143,19 +172,19 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));
143172
144173import { useNewThreadHandler } from "./useHandleNewThread";
145174
146describe("useNewThreadHandler", () => {
147 it.each([
148 ["new", null],
149 [
150 "reusable",
151 {
152 draftId: "draft-existing",
153 environmentId: "environment-ssh",
154 promotedTo: null,
155 threadId: "thread-existing",
156 },
157 ],
158 ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => {
175describe.each([
176 ["new", null],
177 [
178 "reusable",
179 {
180 draftId: "draft-existing",
181 environmentId: "environment-ssh",
182 promotedTo: null,
183 threadId: "thread-existing",
184 },
185 ],
186])("useNewThreadHandler with a %s draft", (_, draft) => {
187 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
159188 testState.reset(draft);
160189 const openThread = useNewThreadHandler();
161190 const pendingOpen = openThread(
......@@ -171,4 +200,58 @@ describe("useNewThreadHandler", () => {
171200 expect(testState.router.navigate).not.toHaveBeenCalled();
172201 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
173202 });
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 );
174257});
apps/web/src/hooks/useHandleNewThread.ts+21-16
......@@ -27,6 +27,7 @@ import {
2727 getProjectOrderKey,
2828 selectProjectGroupingSettings,
2929} from "../logicalProject";
30import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
3031import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
3132import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
3233import {
......@@ -35,7 +36,7 @@ import {
3536 resolveNewThreadModelSelectionOverride,
3637} from "../lib/chatThreadActions";
3738import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
38import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
39import { environmentServerConfigsAtom } from "../state/server";
3940import { resolveThreadRouteTarget } from "../threadRoutes";
4041import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
4142import { useClientSettings } from "./useSettings";
......@@ -61,7 +62,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
6162
6263export function useNewThreadHandler() {
6364 const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
64 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
6565 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
6666 const router = useRouter();
6767 const getCurrentRouteTarget = useCallback(() => {
......@@ -139,10 +139,21 @@ export function useNewThreadHandler() {
139139 candidate.id === projectRef.projectId &&
140140 candidate.environmentId === projectRef.environmentId,
141141 );
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;
142154 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
143155 resolveNewThreadModelSelectionOverride({
144 projectDefaultSelection:
145 project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
156 projectDefaultSelection: projectDefaultModelSelection ?? null,
146157 carrySelection: carryModelSelection,
147158 carrySourceDraftId:
148159 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
......@@ -152,16 +163,16 @@ export function useNewThreadHandler() {
152163 // skipped entirely when a higher-priority source decides, and its
153164 // query atom caches per project after the first call.
154165 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
155 const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null;
166 const consultProjectFile = project !== undefined && projectThreadEnvMode == null;
156167 return resolveDefaultThreadEnvMode({
157 projectSetting: project?.defaultThreadEnvMode,
168 projectSetting: projectThreadEnvMode,
158169 projectFile: consultProjectFile
159170 ? await readT3ProjectFileDefaultThreadEnvMode(
160171 project.environmentId,
161172 project.workspaceRoot,
162173 )
163174 : null,
164 globalDefault: targetServerSettings.defaultThreadEnvMode,
175 globalDefault: projectSettings.settings.defaultThreadEnvMode,
165176 });
166177 };
167178 const logicalProjectKey = project
......@@ -258,7 +269,7 @@ export function useNewThreadHandler() {
258269 envMode: defaultEnvMode,
259270 startFromOrigin: resolveNewDraftStartFromOrigin({
260271 envMode: defaultEnvMode,
261 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
272 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
262273 }),
263274 };
264275 }
......@@ -413,7 +424,7 @@ export function useNewThreadHandler() {
413424 options?.startFromOrigin ??
414425 resolveNewDraftStartFromOrigin({
415426 envMode: initialEnvMode,
416 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
427 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
417428 }),
418429 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
419430 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
......@@ -433,13 +444,7 @@ export function useNewThreadHandler() {
433444 return { draftId, threadId };
434445 })();
435446 },
436 [
437 environmentServerConfigs,
438 getCurrentRouteTarget,
439 primaryServerSettings.newWorktreesStartFromOrigin,
440 projectGroupingSettings,
441 router,
442 ],
447 [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router],
443448 );
444449}
445450
apps/web/src/hooks/useSettings.ts-69
......@@ -27,8 +27,6 @@ import {
2727import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2828import {
2929 filterSharedServerPatch,
30 findSharedSettingsMismatches,
31 pickSharedServerSettings,
3230 splitSharedServerPatch,
3331 supportsSharedSettingsSync,
3432} from "@t3tools/client-runtime/state/shared-settings";
......@@ -493,73 +491,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
493491 return updateSettings;
494492}
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
563494export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) {
564495 return useUpdateSettingsTarget(environmentId);
565496}
apps/web/src/lib/resourceTelemetryState.ts+20-5
......@@ -1,4 +1,8 @@
1import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts";
1import type {
2 EnvironmentId,
3 ResourceTelemetryHistoryInput,
4 ResourceTelemetrySnapshot,
5} from "@t3tools/contracts";
26import * as Cause from "effect/Cause";
37import { useCallback } from "react";
48
......@@ -15,9 +19,14 @@ export interface ResourceTelemetryState {
1519 readonly retry: () => Promise<ResourceTelemetrySnapshot>;
1620}
1721
18export function useResourceTelemetry(): ResourceTelemetryState {
22export function useResourceTelemetry(
23 targetEnvironmentId?: EnvironmentId | null,
24): ResourceTelemetryState {
1925 const primaryEnvironment = usePrimaryEnvironment();
20 const environmentId = primaryEnvironment?.environmentId ?? null;
26 const environmentId =
27 targetEnvironmentId === undefined
28 ? (primaryEnvironment?.environmentId ?? null)
29 : targetEnvironmentId;
2130 const query = useEnvironmentQuery(
2231 environmentId === null
2332 ? null
......@@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState {
4049 return { ...query, retry };
4150}
4251
43export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) {
52export function useResourceTelemetryHistory(
53 input: ResourceTelemetryHistoryInput,
54 targetEnvironmentId?: EnvironmentId | null,
55) {
4456 const primaryEnvironment = usePrimaryEnvironment();
45 const environmentId = primaryEnvironment?.environmentId ?? null;
57 const environmentId =
58 targetEnvironmentId === undefined
59 ? (primaryEnvironment?.environmentId ?? null)
60 : targetEnvironmentId;
4661 return useEnvironmentQuery(
4762 environmentId === null
4863 ? null
apps/web/src/routes/settings.integrations.tsx+1-5
......@@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";
22
33import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings";
44
5function SettingsIntegrationsRoute() {
6 return <IntegrationsSettingsPanel />;
7}
8
95export const Route = createFileRoute("/settings/integrations")({
10 component: SettingsIntegrationsRoute,
6 component: IntegrationsSettingsPanel,
117});
apps/web/src/routes/settings.projects.tsx+1-19
......@@ -1,24 +1,6 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { ProjectsSettings } from "../components/settings/ProjectsSettings";
3import { validateSettingsScopeSearch } from "../components/settings/settingsScope";
43
54export const Route = createFileRoute("/settings/projects")({
6 validateSearch: validateSettingsScopeSearch,
7 component: ProjectsRoute,
5 component: ProjectsSettings,
86});
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";
22import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
33
44import { 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 */
612function SettingsProvidersRoute() {
713 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 );
931}
1032
1133export const Route = createFileRoute("/settings/providers")({
apps/web/src/routes/settings.tsx+139-16
......@@ -1,4 +1,3 @@
1import { RotateCcwIcon } from "lucide-react";
21import {
32 Outlet,
43 createFileRoute,
......@@ -7,18 +6,34 @@ import {
76 useLocation,
87 useNavigate,
98} from "@tanstack/react-router";
10import { useCallback, useEffect, useState } from "react";
11
9import { useCallback, useEffect, useState, type ReactNode } from "react";
10import { RotateCcwIcon } from "lucide-react";
11import { Button } from "../components/ui/button";
1212import { useSettingsRestore } from "../components/settings/SettingsPanels";
13
1314import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
14import { Button } from "../components/ui/button";
1515import { SidebarInset } from "../components/ui/sidebar";
1616import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
1717import { 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 }) {
2036 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
21
2237 return (
2338 <Button
2439 size="xs"
......@@ -27,18 +42,87 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
2742 onClick={() => void restoreDefaults()}
2843 >
2944 <RotateCcwIcon className="mx-1 size-3.5" />
30 Restore defaults
45 Restore device defaults
3146 </Button>
3247 );
3348}
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
35117function SettingsContentLayout() {
36118 const location = useLocation();
37119 const navigate = useNavigate();
38120 const canGoBack = useCanGoBack();
121 const { search, selectScope } = useSettingsScope();
122 const groups = useSettingsProjectGroups();
123 const { environments } = useEnvironments();
39124 const [restoreSignal, setRestoreSignal] = useState(0);
40 const showRestoreDefaults = location.pathname === "/settings/general";
41 const handleRestored = () => setRestoreSignal((value) => value + 1);
125 const showScope = !DEVICE_ONLY_PATHS.has(location.pathname);
42126 const navigateBackWithinApp = useCallback(() => {
43127 if (canGoBack) {
44128 window.history.back();
......@@ -73,17 +157,31 @@ function SettingsContentLayout() {
73157 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
74158 <WorkspacePageHeader electron={isElectron}>
75159 <div className="flex w-full items-center gap-3">
76 <SettingsBreadcrumb pathname={location.pathname} />
77 {showRestoreDefaults ? (
78 <div className="ms-auto flex items-center gap-2">
79 <RestoreDefaultsButton onRestored={handleRestored} />
160 <SettingsBreadcrumb
161 pathname={location.pathname}
162 scope={
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 />
80173 </div>
81174 ) : null}
82175 </div>
83176 </WorkspacePageHeader>
84177
85 <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col">
86 <Outlet />
178 <div
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>
87185 </div>
88186 </div>
89187 </SidebarInset>
......@@ -91,10 +189,35 @@ function SettingsContentLayout() {
91189}
92190
93191function 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 );
95216}
96217
97218export const Route = createFileRoute("/settings")({
219 validateSearch: validateSettingsRouteSearch,
220 search: { middlewares: [retainSettingsScope] },
98221 beforeLoad: async ({ context, location }) => {
99222 if (
100223 context.authGateState.status !== "authenticated" &&
apps/web/src/state/server.ts-9
......@@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make(
100100 get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS,
101101).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
107103const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];
108104
109105/**
......@@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make(
115111 (get): ReadonlyArray<EnvironmentTheme> =>
116112 get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,
117113).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
4141and events, so adding a provider should not require branches throughout the domain or clients.
4242See [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
4453## Durable intent and side effects
4554
4655The event log is the source of truth for orchestration state. The
docs/user/project-settings.md+42-20
......@@ -1,34 +1,56 @@
1# Project settings
1# Settings and project overrides
22
3Open **Settings → Projects**. The project and machine pickers start at **All projects** and
4**All machines**.
3The Settings breadcrumb ends with the environment and project a change applies to. They start
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.
7Select an individual project to override a default. Reset its row to inherit again. Changing a
8default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence
9over machine defaults when the project has no explicit workspace override.
7Preferences saved on this device, such as appearance, confirmations and browser profiles, always
8show and ignore the selection. Everything else is stored on a server. Choose one environment to
9edit its settings, or leave **All environments** to edit every connected environment at once.
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;
12offline machines keep their previous values. Mixed values are indicated when selected machines
13or checkouts disagree. Browser access changes apply when an agent session next starts.
12Choose a project to override settings for it on the selected environments. A layers icon beside
13each server row's title shows where the value comes from: the built-in default, the environment,
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.
16Shared actions apply to inheriting projects; editing a project's actions creates an independent list.
17Reset that list to use shared actions again. Existing project actions are preserved.
18When the selected environments disagree, the control shows **Mixed** in place of a value and the
19layers icon turns amber. Picking a value applies it to every selected environment.
1820
19Project names, icons, removal, and importing actions from a checkout remain project-specific.
20When there are several checkouts, the checkout picker selects which actions and grouping to edit.
21Changing an environment value never touches a project's own override. When projects override the
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
2244## Project icons
2345
24Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies
25to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to
26let T3 Code detect an icon again.
46Select the project and open Project to choose an icon, emoji, or image. The choice applies to
47every checkout in the project group and appears on connected clients. Choose **Automatic** to let
48T3 Code detect an icon again.
2749
2850## Keep the default branch current
2951
30Enable **Automatically pull** to keep the default-branch checkout up to date with its configured
31upstream.
52In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date
53with its configured upstream. Choose an environment to set the default or a project to override it.
3254
3355T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files,
3456or 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
8686resumed after it closed.
8787
8888Change these rules in **Settings → General**. They continue to run when your apps
89are closed. Changes apply to connected environments that support shared settings;
90offline environments and older servers keep their previous values. If connected
91environments disagree, **Apply to all** copies your current settings to those named
92in the warning. Changing a rule does not reopen already settled threads.
89are closed. On web and desktop, choose an environment at the top to change only
90its rules, or **All environments** to update connected environments together.
91Mixed values show where the selected environments disagree. Mobile applies these
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
9496## Link a pull request
9597