diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 77036c212517..d57904b7e7a2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -41,14 +41,8 @@ import { DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - type ServerSettingsPatch, } from "@t3tools/contracts"; -import { - filterSharedServerPatch, - findSharedSettingsMismatches, - pickSharedServerSettings, - supportsSharedSettingsSync, -} from "@t3tools/client-runtime/state/shared-settings"; +import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -588,10 +583,9 @@ function GeneralSettingsSection() { const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; /** - * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first eligible sync target provides the - * reference value. Edits fan out to every eligible target, and a mismatch row - * lets the user push the reference out. + * Mobile edits auto-settle defaults across connected, capable environments. + * The first target supplies the displayed values. Applying them leaves each + * environment's other defaults and overrides intact. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -610,24 +604,20 @@ function AutoSettleSettingsRows() { return null; } - const writeToAll = (patch: ServerSettingsPatch) => { + const writeToAll = (patch: Partial) => { for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: reference.environmentId, - primarySettings: referenceSettings, - primaryCapabilities: reference.serverConfig?.environment.capabilities, - environments: environments.map((environment) => ({ + const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( + { environmentId: reference.environmentId, settings: referenceSettings }, + syncTargets.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, - capabilities: environment.serverConfig?.environment.capabilities, })), - }); + ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; const commitDays = () => { @@ -681,7 +671,7 @@ function AutoSettleSettingsRows() { {mismatches.length > 0 ? ( - Settings differ + Auto-settle defaults differ {mismatches.map((mismatch) => mismatch.label).join(", ")} @@ -689,30 +679,18 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings( - referenceSettings, - reference.serverConfig?.environment.capabilities, - ); for (const mismatch of mismatches) { - const target = environments.find( - (candidate) => candidate.environmentId === mismatch.environmentId, - ); void updateSettings({ environmentId: mismatch.environmentId, - input: { - patch: filterSharedServerPatch( - patch, - target?.serverConfig?.environment.capabilities, - target?.serverConfig?.settings, - referenceSettings, - ), - }, + input: { patch: autoSettlePatch }, }); } }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + Apply auto-settle defaults + ) : null} diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts new file mode 100644 index 000000000000..ec550725adcf --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -0,0 +1,78 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; + +const reference = { + environmentId: EnvironmentId.make("reference"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + newWorktreesStartFromOrigin: false, + continueThreadsAfterServerUpdate: false, + }, +}; + +describe("auto-settle settings sync", () => { + it("ignores differences in independently configured environment settings", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Keep this environment's writing instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + + expect(plan.mismatches).toEqual([]); + expect(plan.patch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + }); + }); + + it("applies only auto-settle defaults when another environment differs", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Preserve these instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + const updated = { ...target.settings, ...plan.patch }; + + expect(plan.mismatches).toEqual([target]); + expect(updated.sidebarAutoSettleAfterDays).toBe(7); + expect(updated.sidebarAutoSettleOnMerge).toBe(true); + expect(updated.newWorktreesStartFromOrigin).toBe(true); + expect(updated.continueThreadsAfterServerUpdate).toBe(true); + expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); + }); + + it("does not compare the reference or a target without loaded settings", () => { + const plan = planAutoSettleSettingsSync(reference, [ + { ...reference, label: "Reference" }, + { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null }, + ]); + + expect(plan.mismatches).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts new file mode 100644 index 000000000000..6addfa381fde --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; + +export type AutoSettleSettings = Pick< + ServerSettings, + "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" +>; + +interface AutoSettleSyncTarget { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly settings: AutoSettleSettings | null; +} + +/** Receives connected, capable targets. Applying these defaults must preserve other settings. */ +export function planAutoSettleSettingsSync( + reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, + targets: readonly AutoSettleSyncTarget[], +) { + const patch: AutoSettleSettings = { + sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, + }; + const mismatches = targets.filter( + (target) => + target.environmentId !== reference.environmentId && + target.settings !== null && + (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || + target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), + ); + return { patch, mismatches }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 283e85365667..0e11910ede7e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -304,7 +304,6 @@ import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -1506,7 +1505,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -5341,7 +5339,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -7920,7 +7918,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -7932,7 +7930,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4a9877f87a02..3533678ecb2d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: { }, }); - // There is no projects listing page; the action targets the contextual - // project (active thread/draft, falling back to the first sidebar group). + // Target the active thread or draft's project, falling back to the first sidebar group. const contextualProjectGroup = (contextualProjectRef ? projectGroupByTargetKey.get( @@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, - search: (previous) => - item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ad7665651171..41c0e9ec3b09 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3133,7 +3133,10 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> - + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( (null); const environmentIdRef = useRef(environmentId); const processDataRef = useRef(processData); - environmentIdRef.current = environmentId; - processDataRef.current = processData; + useEffect(() => { + processDataRef.current = processData; + }, [processData]); + useEffect(() => { + environmentIdRef.current = environmentId; + return () => { + environmentIdRef.current = null; + }; + }, [environmentId]); const openLogsDirectory = useCallback(() => { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; @@ -868,6 +870,9 @@ export function DiagnosticsSettingsPanel() { const isProcessInitialLoading = isProcessPending && processData === null; const signalProcess = useCallback( async (pid: number, signal: ServerProcessSignal) => { + const targetEnvironmentId = environmentIdRef.current; + const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); + if (targetEnvironmentId === null || process === undefined) return; if (signalingPidRef.current !== null) return; signalingPidRef.current = pid; setSignalingPid(pid); @@ -896,20 +901,21 @@ export function DiagnosticsSettingsPanel() { return; } } - const currentEnvironmentId = environmentIdRef.current; - if (currentEnvironmentId === null) { + if (environmentIdRef.current !== targetEnvironmentId) { clearSignaling(); return; } - const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); - if (process === undefined) { + if ( + processDataRef.current?.processes.find((entry) => entry.pid === pid)?.startTimeMs !== + process.startTimeMs + ) { clearSignaling(); return; } try { const result = await signalServerProcess({ - environmentId: currentEnvironmentId, + environmentId: targetEnvironmentId, input: { pid, startTimeMs: process.startTimeMs, signal }, }); if (result._tag === "Failure") { @@ -960,7 +966,7 @@ export function DiagnosticsSettingsPanel() { return ( - + import("./ProjectIconPickerDialog").then((module) => ({ @@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - return useMemo( - () => - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }).sort((a, b) => a.displayName.localeCompare(b.displayName)), - [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], - ); -} - function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } @@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string { export function ProjectSettingsPanel({ projectKey, environmentId = null, + checkoutKey = null, }: { projectKey: string; environmentId?: EnvironmentId | null; + checkoutKey?: string | null; }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); @@ -171,9 +148,11 @@ export function ProjectSettingsPanel({ const members = useMemo( () => selected?.memberProjects.filter( - (member) => environmentId === null || member.environmentId === environmentId, + (member) => + (environmentId === null || member.environmentId === environmentId) && + (checkoutKey === null || member.physicalProjectKey === checkoutKey), ) ?? [], - [selected, environmentId], + [selected, environmentId, checkoutKey], ); // Remember the members of the last rendered group so a grouping-rule change @@ -181,6 +160,7 @@ export function ProjectSettingsPanel({ const lastSelectionRef = useRef<{ key: string; environmentId: EnvironmentId | null; + checkoutKey: string | null; memberKeys: string[]; } | null>(null); useEffect(() => { @@ -188,28 +168,38 @@ export function ProjectSettingsPanel({ lastSelectionRef.current = { key: selected.projectKey, environmentId, + checkoutKey, memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected, members, environmentId]); + }, [selected, members, environmentId, checkoutKey]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey || last.environmentId !== environmentId) return; + if ( + last?.key !== projectKey || + last.environmentId !== environmentId || + last.checkoutKey !== checkoutKey + ) + return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/settings/projects", - search: { project: successor.projectKey, machine: environmentId ?? undefined }, + search: { + project: successor.projectKey, + machine: environmentId ?? undefined, + checkout: checkoutKey ?? undefined, + }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, members.length, environmentId]); + }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]); if (!selected) { return ( @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({ if (members.length === 0) return (

- This project has no checkout on this machine. + This checkout is no longer available in the selected project and environment.

); const scopedGroup = { @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({ }; return ( @@ -879,23 +869,14 @@ function ProjectDetail({ draftStore.clearProjectDraftThreadId(projectRef); } - if (isWholeGroup) { - if (hasOtherMembers) { - void navigate({ - to: "/settings/projects", - search: { project: group.projectKey, machine: undefined }, - replace: true, - }); - } else { - void navigate({ to: "/", replace: true }); - } + if (isWholeGroup && !hasOtherMembers) { + void navigate({ to: "/", replace: true }); } }, [ deleteProject, group.displayName, group.memberProjects.length, - group.projectKey, hasOtherMembers, navigate, reportFailure, diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index acc7326ac866..a3de9b2e44ff 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -1,168 +1,36 @@ -import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { ChevronDownIcon, FolderIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { ProjectFavicon } from "../ProjectFavicon"; -import { WorkspacePageContainer } from "../WorkspacePageContainer"; -import { useEnvironments } from "../../state/environments"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; -import { - Combobox, - ComboboxEmpty, - ComboboxSearchInput, - ComboboxItem, - ComboboxList, - ComboboxPopup, - ComboboxTrigger, -} from "../ui/combobox"; -import { selectTriggerVariants } from "../ui/select"; -import { cn } from "../../lib/utils"; -import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; +import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsScopeNotice } from "./SettingsScopeNotice"; -function ScopePicker({ - label, - value, - options, - onChange, -}: { - label: "project" | "machine"; - value: string | null; - options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; - onChange: (value: string | null) => void; -}) { - const [query, setQuery] = useState(""); - const selected = options.find((option) => option.value === value); - const allIcon = - label === "project" ? : null; - const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options]; - return ( - item.value === (value ?? "all")) ?? null} - inputValue={query} - onInputValueChange={setQuery} - onOpenChange={() => setQuery("")} - onValueChange={(next) => { - if (next) onChange(next.value === "all" ? null : next.value); - }} - > - - - {value === null ? allIcon : selected?.icon} - - {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} - - - - - - - No matching {label}s. - - {(item: (typeof items)[number]) => ( - - {item.icon} - {item.label} - - )} - - - - ); -} - -export function ProjectsSettings({ - projectKey, - machineId, - onScopeChange, -}: { - projectKey: string | null; - machineId: string | null; - onScopeChange: (project: string | null, machine: string | null) => void; -}) { - const groups = useSettingsProjectGroups(); - const { environments } = useEnvironments(); - const machine = environments.find((environment) => environment.environmentId === machineId); - const machineOptions = environments.map((environment) => ({ - value: environment.environmentId, - label: environment.label, - icon: ( - - ), - })); +export function ProjectsSettings() { + const { search: value, scope } = useSettingsScope(); + // The panel follows remembered members when grouping replaces a project key. + const projectScope = + scope.kind === "project" || + scope.kind === "checkout" || + (scope.kind === "unavailable" && + (scope.reason === "project-missing" || scope.reason === "checkout-missing")); return (
-
- -
- {environments.length > 3 ? ( - onScopeChange(projectKey, value)} - /> - ) : ( - { - const value = next[0]; - if (value) onScopeChange(projectKey, value === "all" ? null : value); - }} - > - All machines - {machineOptions.map((option) => ( - - {option.icon} - {option.label} - - ))} - - )} -
- ({ - value: group.projectKey, - label: group.displayName, - icon: , - }))} - onChange={(value) => onScopeChange(value, machineId)} - /> -
-
-
-
- {machineId !== null && !machine ? ( -

This machine is no longer available.

- ) : projectKey === null ? ( - - ) : ( + {value.project && projectScope ? ( + ) : scope.kind === "unavailable" ? ( +

{scope.message}

+ ) : scope.kind === "device" ? ( + + Choose an environment or project to configure project defaults and overrides. + + ) : ( + )}
); } +import { EnvironmentId } from "@t3tools/contracts"; diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 7b386e062c18..f1f1fe1a518e 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -896,6 +896,10 @@ export function ProviderInstanceCard({ className={readOnly ? "opacity-50 select-none" : undefined} >
+

+ Favorites, visibility, and ordering are saved on this device. Custom models are saved + on the selected environment. +

({ readEnvironmentIds: [] as EnvironmentId[], updateEnvironmentIds: [] as EnvironmentId[], updateSettings: vi.fn(), + updateClientSettings: vi.fn(), })); const settingsSearchState = vi.hoisted(() => ({ @@ -81,14 +82,15 @@ vi.mock("../../state/use-atom-command", () => ({ })); vi.mock("../../hooks/useSettings", () => ({ + useUpdateClientSettings: () => settingsState.updateClientSettings, useEnvironmentSettings: (environmentId: EnvironmentId) => { settingsState.readEnvironmentIds.push(environmentId); return settingsState.value; }, - useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { - settingsState.updateEnvironmentIds.push(environmentId); - return settingsState.updateSettings; - }, +})); + +vi.mock("./useScopedSettings", () => ({ + useUpdateScopedSettings: () => settingsState.updateSettings, })); vi.mock("../../environments/primary", () => ({ @@ -177,6 +179,7 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); + settingsState.updateClientSettings.mockReset(); settingsSearchState.targetId = null; settingsSearchState.effects = []; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); @@ -186,7 +189,6 @@ describe("EnvironmentProviderSettings routing", () => { it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { expect(() => renderPanel()).not.toThrow(); expect(settingsState.readEnvironmentIds).toEqual([environmentId]); - expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); }); it("routes refresh and provider update commands to the selected environment", async () => { @@ -230,6 +232,30 @@ describe("EnvironmentProviderSettings routing", () => { expect(editor?.props.instanceId).toBe(customId); }); + it.each([ + ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }], + [ + "onHiddenModelsChange", + { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } }, + ], + [ + "onModelOrderChange", + { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } }, + ], + ])("saves %s on this device without changing the selected server", (action, expected) => { + atoms.providers = [provider()]; + const panel = renderPanel(); + const editor = visitElements( + panel, + (element) => element.props.instanceId === codexId && element.props.mode === "editor", + ); + expect(editor).not.toBeNull(); + if (!editor) throw new Error("Provider editor was not rendered"); + (editor.props[action] as (models: string[]) => void)(["chosen"]); + expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected); + expect(settingsState.updateSettings).not.toHaveBeenCalled(); + }); + it("does not substitute another account when the requested instance was removed", () => { atoms.providers = [provider()]; const panel = renderPanel({ targetInstanceId: customId }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 74676e3ff167..096a41e3d413 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -31,7 +31,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; -import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { useUpdateScopedSettings } from "./useScopedSettings"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; @@ -262,6 +263,7 @@ function EnvironmentUnavailablePlaceholder({ interface ProviderSettingsTarget { readonly environmentId?: EnvironmentId; readonly instanceId?: ProviderInstanceId; + readonly scoped?: boolean; } export function ProviderSettingsPanel(target: ProviderSettingsTarget) { @@ -293,9 +295,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { target.environmentId !== undefined && selectedEnvironmentId === target.environmentId && !options.some((environment) => environment.environmentId === target.environmentId); - const effectiveEnvironmentId = targetEnvironmentMissing - ? target.environmentId - : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId); + const effectiveEnvironmentId = + target.scoped || targetEnvironmentMissing + ? target.environmentId + : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; const selectedEnvironmentCanRenderSettings = @@ -312,6 +315,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { )?.environmentId; useEffect(() => { if ( + !target.scoped && (searchTargetId === searchableSetting("provider-health-check-interval").id || searchTargetId === searchableSetting("usage-providers").id) && !selectedEnvironmentCanRenderSettings && @@ -319,11 +323,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { ) { setSelectedEnvironmentId(searchableEnvironmentId); } - }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); + }, [ + searchTargetId, + searchableEnvironmentId, + selectedEnvironmentCanRenderSettings, + target.scoped, + ]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; const deviceTabs = - !onlyPrimaryDevice && options.length > 0 ? ( + !target.scoped && !onlyPrimaryDevice && options.length > 0 ? ( slug.trim().length > 0))]; const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ + updateClientSettings({ providerModelPreferences: hiddenModels.length === 0 && modelOrder.length === 0 ? rest @@ -841,7 +851,7 @@ export function EnvironmentProviderSettings({ }), ), ]; - updateSettings({ + updateClientSettings({ favorites: [ ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), ...favoriteModels.map((model) => ({ provider: instanceId, model })), diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index 003e46869a91..b52e54bb5f7d 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import type { BackgroundBooleanState, + EnvironmentId, ResourceAttributionEntry, ResourceTelemetryAggregate, ResourceTelemetryHistoryBucket, @@ -26,7 +27,7 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -38,7 +39,6 @@ import { } from "../../lib/resourceTelemetryState"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; -import { usePrimaryEnvironment } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTime } from "../../timestampFormat"; @@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; - const telemetry = useResourceTelemetry(); + const telemetry = useResourceTelemetry(environmentId); const retryTelemetry = telemetry.retry; - const history = useResourceTelemetryHistory({ - windowMs: selectedWindow.windowMs, - bucketMs: selectedWindow.bucketMs, - }); - const primaryEnvironment = usePrimaryEnvironment(); + const history = useResourceTelemetryHistory( + { + windowMs: selectedWindow.windowMs, + bucketMs: selectedWindow.bucketMs, + }, + environmentId, + ); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); const [signalingKeys, setSignalingKeys] = useState>(() => new Set()); const signalingKeysRef = useRef>(new Set()); - signalingKeysRef.current = signalingKeys; - const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId); - primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId; + const environmentIdRef = useRef(environmentId); + useEffect(() => { + environmentIdRef.current = environmentId; + return () => { + environmentIdRef.current = null; + }; + }, [environmentId]); const [isRetrying, setIsRetrying] = useState(false); const snapshot = telemetry.data; const allT3 = snapshot?.groups.allT3; const signalProcess = useCallback( async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { + const targetEnvironmentId = environmentIdRef.current; + if (targetEnvironmentId === null) return; const identityKey = processIdentityKey(process); if (signalingKeysRef.current.has(identityKey)) return; const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); @@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() { return; } } - const environmentId = primaryEnvironmentIdRef.current; - if (environmentId === undefined) { + if (environmentIdRef.current !== targetEnvironmentId) { clearSignaling(); return; } void signalServerProcess({ - environmentId, + environmentId: targetEnvironmentId, input: { pid: process.identity.pid, startTimeMs: process.identity.startTimeMs, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 143680509544..9f3b46902227 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -3,7 +3,6 @@ import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-re import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useAtomValue } from "@effect/atom-react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, @@ -70,13 +69,13 @@ import { useTheme, } from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { useSettingsScope } from "./SettingsScopeContext"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, - withoutPlanAgentSelection, } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -85,13 +84,7 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerConfigAtom, - primaryServerObservabilityAtom, - primaryServerProvidersAtom, -} from "../../state/server"; -import { useProjects } from "../../state/entities"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -118,7 +111,6 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; -import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, @@ -136,7 +128,6 @@ import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, durationToSeconds, - formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, normalizeIntervalSeconds, @@ -488,8 +479,8 @@ export function useSettingsRestore(onRestored?: () => void) { clearThemeHalves, themeHalves, } = useTheme(); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -781,8 +772,8 @@ function BackgroundActivityAdvancedDialog({ readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const activeProfile = resolvedBackgroundActivity.profile; const automaticGitFetchIntervalSeconds = durationToSeconds( @@ -1059,8 +1050,8 @@ export function AppearanceSettingsPanel() { } = useTheme(); const customThemes = useCustomThemes(); const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; @@ -1353,7 +1344,7 @@ export function AppearanceSettingsPanel() { } function useFontDefaultFamilies() { - const settings = usePrimarySettings(); + const settings = useScopedSettings(); // An unset preference shows the font it resolves to on this machine; the // default stacks are the platform's own faces, so the name is probed, not // hardcoded. @@ -1373,8 +1364,8 @@ function useFontDefaultFamilies() { } function InterfaceFontRow({ preview }: { preview?: ReactNode }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const defaults = useFontDefaultFamilies(); return ( } /> @@ -1930,8 +1921,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ * jump to one of the rows unfolds the section. */ function LegacyFeaturesSection() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const [open, setOpen] = useState(false); const searchTargetId = useSettingsSearchTargetId(); const targetRef = useSettingsSearchTarget("legacy-features"); @@ -1969,29 +1960,7 @@ function LegacyFeaturesSection() { { - const planModeEnabled = Boolean(checked); - const textGenerationModelSelection = withoutPlanAgentSelection( - settings.textGenerationModelSelection, - ); - const sourceControlWriterModelSelection = withoutPlanAgentSelection( - settings.sourceControlWriterModelSelection, - ); - updateSettings({ - planModeEnabled, - ...(planModeEnabled - ? {} - : { - ...(textGenerationModelSelection && - textGenerationModelSelection !== settings.textGenerationModelSelection - ? { textGenerationModelSelection } - : {}), - ...(sourceControlWriterModelSelection && - sourceControlWriterModelSelection !== - settings.sourceControlWriterModelSelection - ? { sourceControlWriterModelSelection } - : {}), - }), - }); + updateSettings({ planModeEnabled: Boolean(checked) }); }} aria-label="Plan mode (legacy)" /> @@ -2012,6 +1981,7 @@ function LegacyFeaturesSection() { /> ( readLastEnabledProjectGroupingMode(), ); - const observability = useAtomValue(primaryServerObservabilityAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); + const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const supportsAutoSettlement = - useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; - const diagnosticsDescription = formatDiagnosticsDescription({ - localTracingEnabled: observability?.localTracingEnabled ?? false, - otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, - otlpTracesUrl: observability?.otlpTracesUrl, - otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, - otlpMetricsUrl: observability?.otlpMetricsUrl, - }); + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, + ); + const supportsRestartContinuation = + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, + ); const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -2125,120 +2098,126 @@ export function GeneralSettingsPanel() { return ( - - - + {isDeviceScope || supportsAutoSettlement ? ( + + + updateSettings({ + sidebarProjectGroupingMode: + DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + { + if (!checked && settings.sidebarProjectGroupingMode !== "separate") { + lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode; + rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode); + } updateSettings({ - sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - }) - } + sidebarProjectGroupingMode: projectGroupingModeFromToggle( + checked, + lastEnabledProjectGroupingMode.current, + ), + }); + }} + aria-label="Project grouping" /> - ) : null - } - control={ - { - if (!checked && settings.sidebarProjectGroupingMode !== "separate") { - lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode; - rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode); - } - updateSettings({ - sidebarProjectGroupingMode: projectGroupingModeFromToggle( - checked, - lastEnabledProjectGroupingMode.current, - ), - }); - }} - aria-label="Project grouping" - /> - } - /> + } + /> - {supportsAutoSettlement ? ( - <> - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) + {supportsAutoSettlement ? ( + <> + + updateSettings({ + sidebarAutoSettleOnMerge: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) } + aria-label="Auto-settle merged threads" /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) - } - aria-label="Auto-settle merged threads" - /> - } - /> + } + /> - - updateSettings({ - sidebarAutoSettleAfterDays: - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ + sidebarAutoSettleAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } control={ - updateSettings({ sidebarAutoSettleAfterDays: days })} + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" /> } /> - ) : null} - - ) : null} - + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + ) : null} @@ -2470,6 +2457,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) }) } @@ -2480,6 +2468,7 @@ export function GeneralSettingsPanel() { @@ -2505,7 +2494,7 @@ export function GeneralSettingsPanel() { value={backgroundActivityProfileOption} onValueChange={(value) => { if (value === "advanced") { - setBackgroundActivityDialogOpen(true); + if (isEnvironmentScope) setBackgroundActivityDialogOpen(true); return; } if ( @@ -2536,12 +2525,12 @@ export function GeneralSettingsPanel() { {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]} - + {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} - {backgroundActivityProfileOption === "advanced" ? ( + {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? ( ) : null} @@ -2567,319 +2556,347 @@ export function GeneralSettingsPanel() { /> - - - } - size="sm" - variant="outline" - > - Project settings - - } - /> + {!isDeviceScope ? ( + + previous} />} + size="sm" + variant="outline" + > + Project settings + + } + /> - - updateSettings({ - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) - } - aria-label="Start new worktrees from origin by default" - /> - } - /> - - updateSettings({ - addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, - }) + + updateSettings({ + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } + aria-label="Start new worktrees from origin by default" /> - ) : null - } - control={ - updateSettings({ addProjectBaseDirectory: next })} - placeholder="~/" - spellCheck={false} - aria-label="Add project base directory" - /> - } - /> - - - - - updateSettings({ - confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, - }) - } + } + /> + + updateSettings({ + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ addProjectBaseDirectory: next })} + placeholder="~/" + spellCheck={false} + aria-label="Add project base directory" /> - ) : null - } - control={ - - updateSettings({ confirmThreadUnpin: Boolean(checked) }) - } - aria-label="Confirm thread unpinning" - /> - } - /> + } + /> + + ) : null} - - updateSettings({ - confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, - }) + {isDeviceScope ? ( + + + updateSettings({ + confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadUnpin: Boolean(checked) }) } + aria-label="Confirm thread unpinning" /> - ) : null - } - control={ - - updateSettings({ confirmThreadArchive: Boolean(checked) }) - } - aria-label="Confirm thread archiving" - /> - } - /> + } + /> - - updateSettings({ - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, - }) + + updateSettings({ + confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadArchive: Boolean(checked) }) } + aria-label="Confirm thread archiving" /> - ) : null - } - control={ - - updateSettings({ confirmThreadDelete: Boolean(checked) }) - } - aria-label="Confirm thread deletion" - /> - } - /> + } + /> - {isElectron ? ( - updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + updateSettings({ + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + }) } /> ) : null } control={ - + + updateSettings({ confirmThreadDelete: Boolean(checked) }) + } + aria-label="Confirm thread deletion" + /> } /> - ) : null} - - - - updateSettings({ - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }) - } - /> - ) : null - } - control={ - !hasTextGenerationProvider ? ( - - No text generation providers available. - - ) : ( -
- { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }, - } - : {})} - onInstanceModelChange={(instanceId, model) => { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: createModelSelection(instanceId, model), - }, - textGenerationProviders, - ), - }); + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + ) : null} + + {!isDeviceScope ? ( + + + updateSettings({ + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }) + } /> - {textGenInstanceEntry ? ( - + Select an environment to choose its text generation model. + + ) : !hasTextGenerationProvider ? ( + + No text generation providers available. + + ) : ( +
+ {}} - modelOptions={textGenModelOptions} - allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} + lockedProvider={null} + instanceEntries={textGenerationModelInstanceEntries} + modelOptionsByInstance={textGenerationModelOptionsByInstance} triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} - onModelOptionsChange={(nextOptions) => { + {...(environmentId + ? { + onOpenProviderSetup: (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }, + } + : {})} + onInstanceModelChange={(instanceId, model) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: createModelSelection( - textGenInstanceId, - textGenModel, - nextOptions, - ), + textGenerationModelSelection: createModelSelection(instanceId, model), }, textGenerationProviders, ), }); }} /> - ) : null} -
- ) - } - /> -
+ {textGenInstanceEntry ? ( + {}} + modelOptions={textGenModelOptions} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(nextOptions) => { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: createModelSelection( + textGenInstanceId, + textGenModel, + nextOptions, + ), + }, + textGenerationProviders, + ), + }); + }} + /> + ) : null} +
+ ) + } + /> +
+ ) : null} - - {isElectron || HOSTED_APP_CHANNEL ? ( - - ) : ( + {isDeviceScope ? ( + + {isElectron || HOSTED_APP_CHANNEL ? ( + + ) : ( + } + description="Current version of the application." + /> + )} + + ) : null} + {isEnvironmentScope ? ( + } - description="Current version of the application." + serverScoped + {...searchableSetting("diagnostics")} + description="Inspect processes, resource use, and logs on this environment." + control={ + + } /> - )} - } size="sm" variant="outline"> - View diagnostics - - } - /> - + + ) : null}
@@ -2887,25 +2904,31 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projects = useProjects(); + const { scope } = useSettingsScope(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - const environmentIds = useMemo( - () => [...new Set(projects.map((project) => project.environmentId))], - [projects], - ); const { snapshots: archivedSnapshots, error: archiveError, isLoading: isLoadingArchive, refresh: refreshArchivedThreads, - } = useArchivedThreadSnapshots(environmentIds); + } = useArchivedThreadSnapshots(scope.environmentIds); const archivedGroups = useMemo(() => { + const selectedProjectKeys = + scope.kind === "project" || scope.kind === "checkout" + ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`)) + : null; const projectsByEnvironmentAndId = new Map( archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.projects.map( - (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const, - ), + snapshot.projects + .filter( + (project) => + selectedProjectKeys === null || + selectedProjectKeys.has(`${environmentId}:${project.id}`), + ) + .map( + (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const, + ), ), ); const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => @@ -2939,7 +2962,7 @@ export function ArchivedThreadsPanel() { } } return groups; - }, [archivedSnapshots]); + }, [archivedSnapshots, scope]); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { @@ -3021,7 +3044,7 @@ export function ArchivedThreadsPanel() { ) : ( archivedGroups.map(({ project, threads: projectThreads }, index) => ( } diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx new file mode 100644 index 000000000000..fad84751007d --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -0,0 +1,54 @@ +import { createContext, type ReactNode, useContext, useMemo } from "react"; + +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; +import { selectScopedSettingsEnvironments } from "./scopedSettings"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; + +function useResolvedSettingsScope(search: SettingsScopeSearch) { + const groups = useSettingsProjectGroups(); + const { environments: availableEnvironments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + return useMemo(() => { + const scope = resolveSettingsScope(search, groups, availableEnvironments); + return { + scope, + ...selectScopedSettingsEnvironments(scope, availableEnvironments, primaryEnvironmentId), + }; + }, [availableEnvironments, groups, primaryEnvironmentId, search]); +} + +const SettingsScopeContext = createContext< + | (ReturnType & { + search: SettingsScopeSearch; + selectScope: (next: SettingsScopeSearch) => void; + }) + | null +>(null); + +export function SettingsScopeProvider({ + search, + onChange, + children, +}: { + search: SettingsScopeSearch; + onChange: (next: SettingsScopeSearch) => void; + children: ReactNode; +}) { + const resolved = useResolvedSettingsScope(search); + const value = useMemo( + () => ({ ...resolved, search, selectScope: onChange }), + [onChange, resolved, search], + ); + return {children}; +} + +export function useOptionalSettingsScope() { + return useContext(SettingsScopeContext); +} + +export function useSettingsScope() { + const scope = useOptionalSettingsScope(); + if (scope === null) throw new Error("Settings scope must be read inside SettingsScopeProvider."); + return scope; +} diff --git a/apps/web/src/components/settings/SettingsScopeNotice.tsx b/apps/web/src/components/settings/SettingsScopeNotice.tsx new file mode 100644 index 000000000000..a3b3d60379eb --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeNotice.tsx @@ -0,0 +1,55 @@ +import { Button } from "../ui/button"; +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; +import { SettingsPageContainer } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { useEnvironments } from "../../state/environments"; +import type { SettingsScopeSearch } from "./settingsScope"; + +/** Offer an explicit target change when a category has no settings at this scope. */ +export function SettingsScopeNotice({ + children, + target, +}: { + children: string; + target: "device" | "environment" | "all"; +}) { + const { selectScope } = useSettingsScope(); + const { environments } = useEnvironments(); + const choices: { label: string; search: SettingsScopeSearch }[] = + target === "environment" + ? environments.map((entry) => ({ + label: environments.some( + (other) => other.environmentId !== entry.environmentId && other.label === entry.label, + ) + ? `${entry.label} · ${entry.displayUrl || entry.environmentId}` + : entry.label, + search: { machine: entry.environmentId }, + })) + : [ + { + label: target === "device" ? "Open settings for this device" : "Open all environments", + search: { scope: target }, + }, + ]; + return ( + + + +

{children}

+ + {choices.map((choice) => ( + + ))} + +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsScopePicker.logic.test.ts b/apps/web/src/components/settings/SettingsScopePicker.logic.test.ts new file mode 100644 index 000000000000..bba0dbd0284e --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopePicker.logic.test.ts @@ -0,0 +1,44 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { settingsScopeEnvironmentLabel } from "./SettingsScopePicker.logic"; + +const first = { + environmentId: EnvironmentId.make("first"), + label: "Development", + displayUrl: "https://first.example.com", +}; +const second = { + environmentId: EnvironmentId.make("second"), + label: "Development", + displayUrl: "https://second.example.com", +}; + +describe("settings scope environment labels", () => { + it("distinguishes same-name environments by address", () => { + const environments = [first, second]; + expect( + environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), + ).toEqual([ + "Development · https://first.example.com", + "Development · https://second.example.com", + ]); + }); + + it("falls back to environment IDs when duplicate names have no display URL", () => { + const environments = [first, second].map((environment) => ({ + ...environment, + displayUrl: null, + })); + expect( + environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), + ).toEqual(["Development · first", "Development · second"]); + }); + + it("keeps unique names compact and removes disambiguation after a rename", () => { + expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); + expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( + "Development", + ); + }); +}); diff --git a/apps/web/src/components/settings/SettingsScopePicker.logic.ts b/apps/web/src/components/settings/SettingsScopePicker.logic.ts new file mode 100644 index 000000000000..6ca591af53b0 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopePicker.logic.ts @@ -0,0 +1,16 @@ +import type { EnvironmentPresentation } from "../../state/environments"; + +type ScopeEnvironment = Pick; + +export function settingsScopeEnvironmentLabel( + environment: ScopeEnvironment, + environments: readonly ScopeEnvironment[], +) { + const duplicate = environments.some( + (other) => + other.environmentId !== environment.environmentId && other.label === environment.label, + ); + return duplicate + ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}` + : environment.label; +} diff --git a/apps/web/src/components/settings/SettingsScopePicker.tsx b/apps/web/src/components/settings/SettingsScopePicker.tsx new file mode 100644 index 000000000000..bcb1672e2b8f --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopePicker.tsx @@ -0,0 +1,256 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { ChevronDownIcon, ComputerIcon, FolderIcon, LayersIcon } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { + Combobox, + ComboboxCollection, + ComboboxEmpty, + ComboboxGroup, + ComboboxGroupLabel, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { selectTriggerVariants } from "../ui/select"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { settingsScopeEnvironmentLabel } from "./SettingsScopePicker.logic"; + +interface ScopeOption { + label: string; + searchLabel: string; + detail?: string | undefined; + scope: SettingsScopeSearch; + icon: ReactNode; + indented?: boolean; +} + +function optionKey(scope: SettingsScopeSearch): string { + return JSON.stringify([scope.scope, scope.project, scope.machine, scope.checkout]); +} + +export function SettingsScopePicker({ + value, + groups, + environments, + onChange, + includeDevice = false, +}: { + value: SettingsScopeSearch; + groups: readonly SidebarProjectSnapshot[]; + environments: readonly EnvironmentPresentation[]; + onChange: (next: SettingsScopeSearch) => void; + includeDevice?: boolean; +}) { + const [query, setQuery] = useState(""); + const resolved = resolveSettingsScope(value, groups, environments); + const selectedEnvironment = + resolved.kind === "environment" || resolved.kind === "checkout" || resolved.kind === "project" + ? environments.find((environment) => environment.environmentId === resolved.environmentId) + : undefined; + const selectedEnvironmentLabel = selectedEnvironment + ? settingsScopeEnvironmentLabel(selectedEnvironment, environments) + : undefined; + const triggerLabel = + selectedEnvironmentLabel && resolved.kind === "checkout" + ? `${resolved.group.displayName} / ${selectedEnvironmentLabel} · ${resolved.checkout.workspaceRoot}` + : selectedEnvironmentLabel && resolved.kind === "project" + ? `${resolved.group.displayName} / ${selectedEnvironmentLabel}` + : (selectedEnvironmentLabel ?? resolved.label); + const optionGroups = useMemo(() => { + const environmentById = new Map( + environments.map((environment) => [environment.environmentId, environment]), + ); + const defaults: ScopeOption[] = [ + { + label: "All environments", + searchLabel: "All environments defaults", + scope: { scope: "all" }, + icon: , + }, + ]; + if (includeDevice) { + defaults.push({ + label: "This device", + searchLabel: "This device appearance preferences", + scope: { scope: "device" }, + icon: , + }); + } + return [ + { id: "defaults", label: "Settings for", items: defaults }, + { + id: "environments", + label: "Environments", + items: environments.map((environment): ScopeOption => ({ + label: environment.label, + searchLabel: `${environment.label} ${environment.displayUrl ?? environment.environmentId} environment defaults`, + detail: + [ + environments.some( + (other) => + other.environmentId !== environment.environmentId && + other.label === environment.label, + ) + ? (environment.displayUrl ?? environment.environmentId) + : null, + environment.connection.phase === "connected" ? null : "Offline", + ] + .filter(Boolean) + .join(" · ") || undefined, + scope: { machine: environment.environmentId }, + icon: ( + + ), + })), + }, + ...groups.map((group) => { + const items: ScopeOption[] = [ + { + label: "All checkouts", + searchLabel: `${group.displayName} all checkouts`, + detail: `${group.memberProjects.length} ${group.memberProjects.length === 1 ? "checkout" : "checkouts"}`, + scope: { project: group.projectKey }, + icon: , + }, + ]; + // Old links can select several checkouts on one environment. Keep that + // exact aggregate selected until the user chooses a different target. + if (value.project === group.projectKey && value.machine && !value.checkout) { + const environment = environmentById.get(value.machine); + if ( + environment && + group.memberProjects.some((member) => member.environmentId === value.machine) + ) { + items.push({ + label: `All checkouts on ${settingsScopeEnvironmentLabel(environment, environments)}`, + searchLabel: `${group.displayName} all checkouts ${settingsScopeEnvironmentLabel(environment, environments)}`, + scope: { project: group.projectKey, machine: environment.environmentId }, + icon: , + indented: true, + }); + } + } + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const environmentLabel = environment + ? settingsScopeEnvironmentLabel(environment, environments) + : (member.environmentLabel ?? "Unavailable environment"); + items.push({ + label: environmentLabel, + searchLabel: `${group.displayName} ${environmentLabel} ${member.workspaceRoot}`, + detail: member.workspaceRoot, + scope: { + project: group.projectKey, + machine: member.environmentId, + checkout: member.physicalProjectKey, + }, + icon: ( + + ), + indented: true, + }); + } + return { id: `project:${group.projectKey}`, label: group.displayName, items }; + }), + ].filter((group) => group.items.length > 0); + }, [environments, groups, includeDevice, value.project, value.machine, value.checkout]); + + const normalizedValue: SettingsScopeSearch = + resolved.kind === "all" || resolved.kind === "device" + ? { scope: resolved.kind } + : resolved.kind === "checkout" + ? { + project: resolved.group.projectKey, + machine: resolved.environmentId, + checkout: resolved.checkout.physicalProjectKey, + } + : value; + const selected = + resolved.kind === "unavailable" + ? null + : (optionGroups + .flatMap((group) => group.items) + .find((option) => optionKey(option.scope) === optionKey(normalizedValue)) ?? null); + + return ( + item.searchLabel} + isItemEqualToValue={(item, selectedValue) => + optionKey(item.scope) === optionKey(selectedValue.scope) + } + inputValue={query} + onInputValueChange={setQuery} + onOpenChange={() => setQuery("")} + onValueChange={(next) => { + if (next) onChange(next.scope); + }} + > + + + {selected?.icon} + {triggerLabel} + + + + + + No matching environments or projects. + + {(group: (typeof optionGroups)[number]) => ( + + {group.label} + + {(item: ScopeOption) => ( + + {item.icon} + + + {item.label} + + {item.detail ? ( + + {item.detail} + + ) : null} + + + )} + + + )} + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 75624792c5d7..9592be3d546b 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -181,18 +181,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if ( - item.to !== "/settings/projects" && - pathname === item.to && - currentHash.replace(/^#/, "") === targetId - ) { + if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, - search: (previous) => - item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, diff --git a/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx b/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx deleted file mode 100644 index 2a804cf9aeb2..000000000000 --- a/apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { TriangleAlertIcon } from "lucide-react"; - -import { useSharedSettingsSync } from "../../hooks/useSettings"; -import { Alert, AlertAction, AlertDescription } from "../ui/alert"; -import { Button } from "../ui/button"; - -/** - * Warns when a connected environment holds different shared settings than - * the primary one, and offers to write the primary's values everywhere. - * Renders nothing when every connected environment agrees. - */ -export function SharedSettingsMismatchAlert() { - const { mismatches, applyToAll } = useSharedSettingsSync(); - if (mismatches.length === 0) { - return null; - } - const labels = mismatches.map((mismatch) => mismatch.label).join(", "); - return ( - - - - Settings differ on {labels}. Thread and source control preferences are meant to match on - every environment. - - - - - - ); -} diff --git a/apps/web/src/components/settings/SnapShotSettings.test.tsx b/apps/web/src/components/settings/SnapShotSettings.test.tsx index 8d34af17a058..584c224493dc 100644 --- a/apps/web/src/components/settings/SnapShotSettings.test.tsx +++ b/apps/web/src/components/settings/SnapShotSettings.test.tsx @@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => { }); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); const bridge = vi.hoisted(() => ({ getSnapShotState: vi.fn<() => Promise>(), setSnapShotShortcutSuppressed: vi.fn(), diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 6d9d20105224..a5cd31408cad 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -18,10 +18,9 @@ import { resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; -import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { useSettingsScope } from "./SettingsScopeContext"; import { cn } from "../../lib/utils"; -import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; @@ -343,8 +342,8 @@ function DiscoveryItemRow({ } function GitFetchIntervalSettings() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const automaticGitFetchIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.automaticGitFetchInterval, @@ -498,15 +497,11 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const { environments } = useEnvironments(); - const primaryEnvironment = usePrimaryEnvironment(); - const fallbackEnvironment = - environments.find((environment) => environment.connection.phase === "connected") ?? - environments[0] ?? - null; + const { scope, environment } = useSettingsScope(); const environmentId = - primaryEnvironment?.environmentId ?? fallbackEnvironment?.environmentId ?? null; - const isPrimaryEnvironment = environmentId === primaryEnvironment?.environmentId; + scope.kind === "environment" && environment?.connection.phase === "connected" + ? scope.environmentId + : null; const discovery = useEnvironmentQuery( environmentId === null ? null @@ -543,8 +538,15 @@ export function SourceControlSettingsPanel() { return ( - - {isInitialScanPending ? ( + {environmentId === null ? ( + +

+ {scope.kind === "environment" + ? "Connect this environment to inspect its version control tools and hosting integrations." + : "Select an environment to inspect its version control tools and hosting integrations."} +

+
+ ) : isInitialScanPending ? ( <> @@ -559,9 +561,7 @@ export function SourceControlSettingsPanel() { > {result.versionControlSystems.map((item) => ( - {item.kind === "git" && isPrimaryEnvironment ? ( - - ) : undefined} + {item.kind === "git" ? : undefined} ))}
@@ -587,8 +587,6 @@ export function SourceControlSettingsPanel() { /> )} - {/* Its rows are serverScoped: without a primary they render inert with - an explanation, which beats disappearing. */} ); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx new file mode 100644 index 000000000000..6db4c50b4415 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.test.tsx @@ -0,0 +1,212 @@ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ScopedSettingsPatch } from "./scopedSettings"; + +type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; +const state = vi.hoisted(() => ({ + styles: [] as WritingStyle[], + updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), +})); + +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("./useScopedSettings", () => ({ + useScopedSettings: () => ({ + ...DEFAULT_UNIFIED_SETTINGS, + sourceControlWritingStyle: state.styles[0], + }), + useScopedSettingsMixed: () => JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]), + useUpdateScopedSettings: () => state.updateSettings, +})); +vi.mock("./SettingsScopeContext", () => ({ + useSettingsScope: () => ({ scope: { kind: "all" }, environment: null }), +})); +vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] })); +vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null })); +vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) })); +vi.mock("./settingsLayout", () => ({ + SETTINGS_PICKER_TRIGGER_CLASSNAME: "", + SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => ( + + ), + SettingsSection: ({ children }: { children: ReactNode }) => children, + SettingsRow: ({ + children, + control, + resetAction, + }: { + children: ReactNode; + control: ReactNode; + resetAction: ReactNode; + }) => ( +
+ {control} + {resetAction} + {children} +
+ ), +})); +vi.mock("../ui/select", () => ({ + Select: ({ children }: { children: ReactNode }) => children, + SelectItem: "span", + SelectPopup: "div", + SelectTrigger: "div", + SelectValue: "span", +})); +vi.mock("../ui/switch", () => ({ Switch: "input" })); +vi.mock("../ui/textarea", () => ({ Textarea: "textarea" })); +vi.mock("../ui/button", () => ({ Button: "button" })); + +import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; + +let renderer: ReactTestRenderer | null; + +function button(label: string) { + return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!; +} + +function openEditor() { + act(() => button("Write custom instructions for all").props.onClick()); +} + +function editInstructions(value: string) { + act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } })); +} + +function applyInstructions() { + act(() => button("Apply instructions to all").props.onClick()); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.styles = [ + { + ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, + mode: "custom", + customInstructions: "First environment instructions", + followChangeRequestTemplates: true, + }, + { + ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, + mode: "custom", + customInstructions: "Second environment instructions", + followChangeRequestTemplates: false, + }, + ]; + state.updateSettings.mockReset().mockImplementation((patch) => { + state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle })); + }); + act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +describe("mixed source control instructions", () => { + it("resets mixed template preferences without replacing each environment's instructions", () => { + const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({ + mode, + customInstructions, + })); + + act(() => button("Reset change request templates").props.onClick()); + + expect(state.updateSettings).toHaveBeenCalledTimes(1); + expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([ + DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates, + DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates, + ]); + expect( + state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), + ).toEqual(initialInstructions); + }); + + it("resets every environment even when the representative already has default instructions", () => { + state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle }; + act(() => { + renderer!.update( + + + , + ); + }); + + act(() => button("Reset source control writing style").props.onClick()); + + expect(state.updateSettings).toHaveBeenCalledTimes(1); + expect( + state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), + ).toEqual( + [0, 1].map(() => ({ + mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode, + customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions, + })), + ); + expect(state.styles[1]!.followChangeRequestTemplates).toBe(false); + }); + + it("does not write an untouched bulk draft", () => { + const initialStyles = state.styles; + openEditor(); + expect(renderer!.root.findByType("textarea").props.value).toBe(""); + expect(button("Apply instructions to all").props.disabled).toBe(true); + + applyInstructions(); + expect(state.updateSettings).not.toHaveBeenCalled(); + expect(state.styles).toEqual(initialStyles); + }); + + it("applies edited instructions to every selected environment", () => { + openEditor(); + editInstructions(" Keep titles concise. "); + expect(button("Apply instructions to all").props.disabled).toBe(false); + applyInstructions(); + + expect(state.updateSettings).toHaveBeenCalledTimes(1); + expect(state.styles.map((style) => style.customInstructions)).toEqual([ + "Keep titles concise.", + "Keep titles concise.", + ]); + expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]); + }); + + it("allows an intentional clear after editing", () => { + openEditor(); + editInstructions("Temporary instructions"); + editInstructions(""); + expect(button("Apply instructions to all").props.disabled).toBe(false); + applyInstructions(); + + expect(state.updateSettings).toHaveBeenCalledTimes(1); + expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]); + }); + + it("resets the draft when the bulk editor reopens", () => { + openEditor(); + editInstructions("Shared instructions"); + applyInstructions(); + + // Template preferences still differ, so this row remains mixed after the save. + openEditor(); + expect(renderer!.root.findByType("textarea").props.value).toBe(""); + expect(button("Apply instructions to all").props.disabled).toBe(true); + applyInstructions(); + + expect(state.updateSettings).toHaveBeenCalledTimes(1); + expect(state.styles.map((style) => style.customInstructions)).toEqual([ + "Shared instructions", + "Shared instructions", + ]); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 448c6c623ea8..93c0db09edfc 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -1,12 +1,16 @@ -import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; +import { useSettingsScope } from "./SettingsScopeContext"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -16,12 +20,12 @@ import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; -import { primaryServerProvidersAtom } from "../../state/server"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; +import { Button } from "../ui/button"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -48,16 +52,22 @@ const MODE_OPTIONS: Record(null); + const [editingAllInstructions, setEditingAllInstructions] = useState(false); + const [allInstructions, setAllInstructions] = useState(null); const style = settings.sourceControlWritingStyle; const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; const isSourceControlWritingStyleDirty = - style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; + writingStyleMixed || + style.mode !== defaults.mode || + style.customInstructions !== defaults.customInstructions; const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -92,6 +102,7 @@ export function SourceControlWritingSettingsSection() { } > - {style.mode === "custom" ? ( + {writingStyleMixed ? ( +
+ {editingAllInstructions ? ( + <> +