From 02c138d981a7cc6aa7407cab9a640091326cb974 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 18:46:02 -0700 Subject: [PATCH 01/31] feat(settings): keep scope across pages and isolate environment edits Lift the target picker into Settings, separate device preferences from environment defaults, and preserve target URLs across category navigation. Read and write the chosen environment, show mixed values, and keep runtime and mobile consumers from overwriting unrelated defaults. --- .../features/settings/SettingsRouteScreen.tsx | 52 +- .../settings/autoSettleSettingsSync.test.ts | 78 ++ .../settings/autoSettleSettingsSync.ts | 31 + apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/CommandPalette.tsx | 5 +- .../settings/ConnectionsSettings.tsx | 5 +- .../settings/DiagnosticsSettings.tsx | 46 +- .../settings/KeybindingsSettings.tsx | 19 +- .../components/settings/ProjectsSettings.tsx | 33 +- .../settings/ProviderInstanceCard.tsx | 4 + ...ProviderSettingsPanel.environment.test.tsx | 36 +- .../settings/ProviderSettingsPanel.tsx | 28 +- .../settings/ResourceTelemetryDiagnostics.tsx | 41 +- .../components/settings/SettingsPanels.tsx | 945 +++++++++--------- .../settings/SettingsScopeContext.tsx | 54 + .../settings/SettingsScopeNotice.tsx | 48 + .../settings/SettingsSidebarNav.tsx | 8 +- .../settings/SourceControlSettings.tsx | 38 +- .../settings/SourceControlWritingSettings.tsx | 168 ++-- .../settings/scopedSettings.test.ts | 236 +++++ .../src/components/settings/scopedSettings.ts | 124 +++ .../components/settings/settingsLayout.tsx | 39 +- .../settings/settingsScopeNavigation.test.ts | 204 ++++ .../settings/settingsScopeNavigation.ts | 24 + .../components/settings/useScopedSettings.ts | 77 ++ apps/web/src/hooks/useHandleNewThread.test.ts | 116 ++- apps/web/src/hooks/useHandleNewThread.ts | 15 +- apps/web/src/lib/resourceTelemetryState.ts | 25 +- apps/web/src/routes/settings.projects.tsx | 20 +- apps/web/src/routes/settings.providers.tsx | 11 +- apps/web/src/routes/settings.tsx | 192 +++- 31 files changed, 1974 insertions(+), 756 deletions(-) create mode 100644 apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts create mode 100644 apps/mobile/src/features/settings/autoSettleSettingsSync.ts create mode 100644 apps/web/src/components/settings/SettingsScopeContext.tsx create mode 100644 apps/web/src/components/settings/SettingsScopeNotice.tsx create mode 100644 apps/web/src/components/settings/scopedSettings.test.ts create mode 100644 apps/web/src/components/settings/scopedSettings.ts create mode 100644 apps/web/src/components/settings/settingsScopeNavigation.test.ts create mode 100644 apps/web/src/components/settings/settingsScopeNavigation.ts create mode 100644 apps/web/src/components/settings/useScopedSettings.ts 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 51b9c5eabc48..10694115ef0a 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"; @@ -1516,7 +1515,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, ); @@ -5396,7 +5394,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -8045,7 +8043,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 } : {}), }); @@ -8057,7 +8055,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 ( - + void; -}) { - const groups = useSettingsProjectGroups(); - const { environments } = useEnvironments(); - const scope = resolveSettingsScope(value, groups, environments); +export function ProjectsSettings() { + const { search: value, scope } = useSettingsScope(); // The panel follows remembered members when grouping replaces a project key. const projectScope = scope.kind === "project" || @@ -24,16 +13,6 @@ export function ProjectsSettings({ (scope.reason === "project-missing" || scope.reason === "checkout-missing")); return (
-
- - - -
{value.project && projectScope ? ( ) : scope.kind === "unavailable" ? (

{scope.message}

+ ) : scope.kind === "device" ? ( + + Choose an environment or project to configure project defaults and overrides. + ) : (
+

+ 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..d239b611d78c --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeNotice.tsx @@ -0,0 +1,48 @@ +import { Button } from "../ui/button"; +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: 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/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/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.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 448c6c623ea8..5ae2081e3101 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,12 +52,16 @@ const MODE_OPTIONS: Record(null); + const [editingAllInstructions, setEditingAllInstructions] = useState(false); + const [allInstructions, setAllInstructions] = useState(""); const style = settings.sourceControlWritingStyle; const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; const isSourceControlWritingStyleDirty = @@ -92,6 +100,7 @@ export function SourceControlWritingSettingsSection() { } > - {style.mode === "custom" ? ( + {writingStyleMixed ? ( +
+ {editingAllInstructions ? ( + <> +