| author | |
| committer | |
| log | e22040dfc190d04573238c5db2e69df72e5f1017 |
| tree | a0b076797d4400e90a0b0e89898553ee3b4e792e |
| parent | 8b2c0465def151f022e01dd04b1b6ba3859bd714 |
| signature | Signed by PGP key B5690EEEBB952194 |
71 files changed, 5300 insertions(+), 2675 deletions(-)
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+15-37| ... | @@ -41,14 +41,8 @@ import { | ... | @@ -41,14 +41,8 @@ import { |
| 41 | DEFAULT_SERVER_SETTINGS, | 41 | DEFAULT_SERVER_SETTINGS, |
| 42 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | 42 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 43 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | 43 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 44 | type ServerSettingsPatch, | ||
| 45 | } from "@t3tools/contracts"; | 44 | } from "@t3tools/contracts"; |
| 46 | import { | 45 | import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; |
| 47 | filterSharedServerPatch, | ||
| 48 | findSharedSettingsMismatches, | ||
| 49 | pickSharedServerSettings, | ||
| 50 | supportsSharedSettingsSync, | ||
| 51 | } from "@t3tools/client-runtime/state/shared-settings"; | ||
| 52 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 46 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 53 | import { | 47 | import { |
| 54 | type AppUpdateCheckState, | 48 | type AppUpdateCheckState, |
| ... | @@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow"; | ... | @@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow"; |
| 61 | import { SettingsSection } from "./components/SettingsSection"; | 55 | import { SettingsSection } from "./components/SettingsSection"; |
| 62 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | 56 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| 63 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; | 57 | import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; |
| 58 | import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; | ||
| 64 | 59 | ||
| 65 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | 60 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; |
| 66 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | 61 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; |
| ... | @@ -588,10 +583,9 @@ function GeneralSettingsSection() { | ... | @@ -588,10 +583,9 @@ function GeneralSettingsSection() { |
| 588 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | 583 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; |
| 589 | 584 | ||
| 590 | /** | 585 | /** |
| 591 | * Auto-settlement is a user preference that every server has to hold. Mobile | 586 | * Mobile edits auto-settle defaults across connected, capable environments. |
| 592 | * has no primary environment, so the first eligible sync target provides the | 587 | * The first target supplies the displayed values. Applying them leaves each |
| 593 | * reference value. Edits fan out to every eligible target, and a mismatch row | 588 | * environment's other defaults and overrides intact. |
| 594 | * lets the user push the reference out. | ||
| 595 | */ | 589 | */ |
| 596 | function AutoSettleSettingsRows() { | 590 | function AutoSettleSettingsRows() { |
| 597 | const { environments } = useEnvironments(); | 591 | const { environments } = useEnvironments(); |
| ... | @@ -610,24 +604,20 @@ function AutoSettleSettingsRows() { | ... | @@ -610,24 +604,20 @@ function AutoSettleSettingsRows() { |
| 610 | return null; | 604 | return null; |
| 611 | } | 605 | } |
| 612 | 606 | ||
| 613 | const writeToAll = (patch: ServerSettingsPatch) => { | 607 | const writeToAll = (patch: Partial<AutoSettleSettings>) => { |
| 614 | for (const environment of syncTargets) { | 608 | for (const environment of syncTargets) { |
| 615 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); | 609 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); |
| 616 | } | 610 | } |
| 617 | }; | 611 | }; |
| 618 | 612 | ||
| 619 | const mismatches = findSharedSettingsMismatches({ | 613 | const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( |
| 620 | primaryEnvironmentId: reference.environmentId, | 614 | { environmentId: reference.environmentId, settings: referenceSettings }, |
| 621 | primarySettings: referenceSettings, | 615 | syncTargets.map((environment) => ({ |
| 622 | primaryCapabilities: reference.serverConfig?.environment.capabilities, | ||
| 623 | environments: environments.map((environment) => ({ | ||
| 624 | environmentId: environment.environmentId, | 616 | environmentId: environment.environmentId, |
| 625 | label: environment.label, | 617 | label: environment.label, |
| 626 | syncEligible: supportsSharedSettingsSync(environment), | ||
| 627 | settings: environment.serverConfig?.settings ?? null, | 618 | settings: environment.serverConfig?.settings ?? null, |
| 628 | capabilities: environment.serverConfig?.environment.capabilities, | ||
| 629 | })), | 619 | })), |
| 630 | }); | 620 | ); |
| 631 | 621 | ||
| 632 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | 622 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; |
| 633 | const commitDays = () => { | 623 | const commitDays = () => { |
| ... | @@ -681,7 +671,7 @@ function AutoSettleSettingsRows() { | ... | @@ -681,7 +671,7 @@ function AutoSettleSettingsRows() { |
| 681 | {mismatches.length > 0 ? ( | 671 | {mismatches.length > 0 ? ( |
| 682 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | 672 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> |
| 683 | <View className="min-w-0 flex-1"> | 673 | <View className="min-w-0 flex-1"> |
| 684 | <Text className="text-lg text-foreground">Settings differ</Text> | 674 | <Text className="text-lg text-foreground">Auto-settle defaults differ</Text> |
| 685 | <Text className="text-sm text-foreground-muted"> | 675 | <Text className="text-sm text-foreground-muted"> |
| 686 | {mismatches.map((mismatch) => mismatch.label).join(", ")} | 676 | {mismatches.map((mismatch) => mismatch.label).join(", ")} |
| 687 | </Text> | 677 | </Text> |
| ... | @@ -689,30 +679,18 @@ function AutoSettleSettingsRows() { | ... | @@ -689,30 +679,18 @@ function AutoSettleSettingsRows() { |
| 689 | <Pressable | 679 | <Pressable |
| 690 | accessibilityRole="button" | 680 | accessibilityRole="button" |
| 691 | onPress={() => { | 681 | onPress={() => { |
| 692 | const patch = pickSharedServerSettings( | ||
| 693 | referenceSettings, | ||
| 694 | reference.serverConfig?.environment.capabilities, | ||
| 695 | ); | ||
| 696 | for (const mismatch of mismatches) { | 682 | for (const mismatch of mismatches) { |
| 697 | const target = environments.find( | ||
| 698 | (candidate) => candidate.environmentId === mismatch.environmentId, | ||
| 699 | ); | ||
| 700 | void updateSettings({ | 683 | void updateSettings({ |
| 701 | environmentId: mismatch.environmentId, | 684 | environmentId: mismatch.environmentId, |
| 702 | input: { | 685 | input: { patch: autoSettlePatch }, |
| 703 | patch: filterSharedServerPatch( | ||
| 704 | patch, | ||
| 705 | target?.serverConfig?.environment.capabilities, | ||
| 706 | target?.serverConfig?.settings, | ||
| 707 | referenceSettings, | ||
| 708 | ), | ||
| 709 | }, | ||
| 710 | }); | 686 | }); |
| 711 | } | 687 | } |
| 712 | }} | 688 | }} |
| 713 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" | 689 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" |
| 714 | > | 690 | > |
| 715 | <Text className="text-base font-t3-medium text-foreground">Apply to all</Text> | 691 | <Text className="text-base font-t3-medium text-foreground"> |
| 692 | Apply auto-settle defaults | ||
| 693 | </Text> | ||
| 716 | </Pressable> | 694 | </Pressable> |
| 717 | </View> | 695 | </View> |
| 718 | ) : null} | 696 | ) : null} |
apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; | ||
| 5 | |||
| 6 | const reference = { | ||
| 7 | environmentId: EnvironmentId.make("reference"), | ||
| 8 | settings: { | ||
| 9 | ...DEFAULT_SERVER_SETTINGS, | ||
| 10 | sidebarAutoSettleAfterDays: 7, | ||
| 11 | sidebarAutoSettleOnMerge: true, | ||
| 12 | newWorktreesStartFromOrigin: false, | ||
| 13 | continueThreadsAfterServerUpdate: false, | ||
| 14 | }, | ||
| 15 | }; | ||
| 16 | |||
| 17 | describe("auto-settle settings sync", () => { | ||
| 18 | it("ignores differences in independently configured environment settings", () => { | ||
| 19 | const target = { | ||
| 20 | environmentId: EnvironmentId.make("remote"), | ||
| 21 | label: "Remote", | ||
| 22 | settings: { | ||
| 23 | ...reference.settings, | ||
| 24 | newWorktreesStartFromOrigin: true, | ||
| 25 | continueThreadsAfterServerUpdate: true, | ||
| 26 | sourceControlWritingStyle: { | ||
| 27 | ...reference.settings.sourceControlWritingStyle, | ||
| 28 | customInstructions: "Keep this environment's writing instructions.", | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | }; | ||
| 32 | |||
| 33 | const plan = planAutoSettleSettingsSync(reference, [target]); | ||
| 34 | |||
| 35 | expect(plan.mismatches).toEqual([]); | ||
| 36 | expect(plan.patch).toEqual({ | ||
| 37 | sidebarAutoSettleAfterDays: 7, | ||
| 38 | sidebarAutoSettleOnMerge: true, | ||
| 39 | }); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("applies only auto-settle defaults when another environment differs", () => { | ||
| 43 | const target = { | ||
| 44 | environmentId: EnvironmentId.make("remote"), | ||
| 45 | label: "Remote", | ||
| 46 | settings: { | ||
| 47 | ...reference.settings, | ||
| 48 | sidebarAutoSettleAfterDays: null, | ||
| 49 | sidebarAutoSettleOnMerge: false, | ||
| 50 | newWorktreesStartFromOrigin: true, | ||
| 51 | continueThreadsAfterServerUpdate: true, | ||
| 52 | sourceControlWritingStyle: { | ||
| 53 | ...reference.settings.sourceControlWritingStyle, | ||
| 54 | customInstructions: "Preserve these instructions.", | ||
| 55 | }, | ||
| 56 | }, | ||
| 57 | }; | ||
| 58 | |||
| 59 | const plan = planAutoSettleSettingsSync(reference, [target]); | ||
| 60 | const updated = { ...target.settings, ...plan.patch }; | ||
| 61 | |||
| 62 | expect(plan.mismatches).toEqual([target]); | ||
| 63 | expect(updated.sidebarAutoSettleAfterDays).toBe(7); | ||
| 64 | expect(updated.sidebarAutoSettleOnMerge).toBe(true); | ||
| 65 | expect(updated.newWorktreesStartFromOrigin).toBe(true); | ||
| 66 | expect(updated.continueThreadsAfterServerUpdate).toBe(true); | ||
| 67 | expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("does not compare the reference or a target without loaded settings", () => { | ||
| 71 | const plan = planAutoSettleSettingsSync(reference, [ | ||
| 72 | { ...reference, label: "Reference" }, | ||
| 73 | { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null }, | ||
| 74 | ]); | ||
| 75 | |||
| 76 | expect(plan.mismatches).toEqual([]); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/mobile/src/features/settings/autoSettleSettingsSync.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type AutoSettleSettings = Pick< | ||
| 4 | ServerSettings, | ||
| 5 | "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" | ||
| 6 | >; | ||
| 7 | |||
| 8 | interface AutoSettleSyncTarget { | ||
| 9 | readonly environmentId: EnvironmentId; | ||
| 10 | readonly label: string; | ||
| 11 | readonly settings: AutoSettleSettings | null; | ||
| 12 | } | ||
| 13 | |||
| 14 | /** Receives connected, capable targets. Applying these defaults must preserve other settings. */ | ||
| 15 | export function planAutoSettleSettingsSync( | ||
| 16 | reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, | ||
| 17 | targets: readonly AutoSettleSyncTarget[], | ||
| 18 | ) { | ||
| 19 | const patch: AutoSettleSettings = { | ||
| 20 | sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, | ||
| 21 | sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, | ||
| 22 | }; | ||
| 23 | const mismatches = targets.filter( | ||
| 24 | (target) => | ||
| 25 | target.environmentId !== reference.environmentId && | ||
| 26 | target.settings !== null && | ||
| 27 | (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || | ||
| 28 | target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), | ||
| 29 | ); | ||
| 30 | return { patch, mismatches }; | ||
| 31 | } | ||
apps/mobile/src/features/threads/new-task-flow-provider.tsx+22-9| ... | @@ -13,10 +13,12 @@ import { | ... | @@ -13,10 +13,12 @@ import { |
| 13 | CommandId, | 13 | CommandId, |
| 14 | DEFAULT_PROVIDER_INTERACTION_MODE, | 14 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 15 | DEFAULT_RUNTIME_MODE, | 15 | DEFAULT_RUNTIME_MODE, |
| 16 | DEFAULT_SERVER_SETTINGS, | ||
| 16 | MessageId, | 17 | MessageId, |
| 17 | T3_PROJECT_FILE_NAME, | 18 | T3_PROJECT_FILE_NAME, |
| 18 | ThreadId, | 19 | ThreadId, |
| 19 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 20 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | 22 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 21 | import { | 23 | import { |
| 22 | isDefaultThreadEnvModeSettled, | 24 | isDefaultThreadEnvModeSettled, |
| ... | @@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 428 | if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; | 430 | if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; |
| 429 | return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; | 431 | return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; |
| 430 | }, [t3ProjectFileData]); | 432 | }, [t3ProjectFileData]); |
| 433 | // Environment settings with the project's overrides applied; the | ||
| 434 | // aggregate's own legacy fields still count until the server folds them. | ||
| 435 | const projectSettings = useMemo( | ||
| 436 | () => | ||
| 437 | resolveProjectSettings( | ||
| 438 | selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, | ||
| 439 | selectedProject?.id ?? null, | ||
| 440 | selectedProject, | ||
| 441 | ), | ||
| 442 | [selectedEnvironmentServerConfig?.settings, selectedProject], | ||
| 443 | ); | ||
| 444 | const projectThreadEnvMode = | ||
| 445 | projectSettings.sources.defaultThreadEnvMode === "project" | ||
| 446 | ? projectSettings.settings.defaultThreadEnvMode | ||
| 447 | : undefined; | ||
| 431 | const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ | 448 | const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ |
| 432 | projectSetting: selectedProject?.defaultThreadEnvMode, | 449 | projectSetting: projectThreadEnvMode, |
| 433 | projectFile: t3ProjectFileDefaultMode, | 450 | projectFile: t3ProjectFileDefaultMode, |
| 434 | globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", | 451 | globalDefault: projectSettings.settings.defaultThreadEnvMode, |
| 435 | }); | 452 | }); |
| 436 | // While unsettled the resolved default is provisional. Nothing may write | 453 | // While unsettled the resolved default is provisional. Nothing may write |
| 437 | // it into the draft during that window (the auto-branch effect does), or | 454 | // it into the draft during that window (the auto-branch effect does), or |
| 438 | // the frozen interim value beats the t3.json default once it loads. | 455 | // the frozen interim value beats the t3.json default once it loads. |
| 439 | const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ | 456 | const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ |
| 440 | explicitMode: selectedProjectDraft.workspaceSelection?.mode, | 457 | explicitMode: selectedProjectDraft.workspaceSelection?.mode, |
| 441 | projectSetting: selectedProject?.defaultThreadEnvMode, | 458 | projectSetting: projectThreadEnvMode, |
| 442 | projectFilePending: t3ProjectFileQuery.isPending, | 459 | projectFilePending: t3ProjectFileQuery.isPending, |
| 443 | }); | 460 | }); |
| 444 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; | 461 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; |
| ... | @@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 449 | // value keeps tracking the server setting when the config loads late. | 466 | // value keeps tracking the server setting when the config loads late. |
| 450 | const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; | 467 | const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; |
| 451 | const startFromOrigin = | 468 | const startFromOrigin = |
| 452 | draftStartFromOrigin ?? | 469 | draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; |
| 453 | selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? | ||
| 454 | true; | ||
| 455 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 470 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 456 | 471 | ||
| 457 | // Antigravity keeps unavailable selections so sign-out or a catalog change | 472 | // Antigravity keeps unavailable selections so sign-out or a catalog change |
| ... | @@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 463 | ); | 478 | ); |
| 464 | const projectDefaultModelSelection = resolveDefaultableModelSelection( | 479 | const projectDefaultModelSelection = resolveDefaultableModelSelection( |
| 465 | selectedEnvironmentServerConfig, | 480 | selectedEnvironmentServerConfig, |
| 466 | selectedProject?.defaultModelSelection ?? | 481 | projectSettings.settings.defaultModelSelection, |
| 467 | selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? | ||
| 468 | null, | ||
| 469 | ); | 482 | ); |
| 470 | const storedStickyModelSelection = useStickyComposerModelSelection(); | 483 | const storedStickyModelSelection = useStickyComposerModelSelection(); |
| 471 | const stickyModelSelection = resolveDefaultableModelSelection( | 484 | const stickyModelSelection = resolveDefaultableModelSelection( |
apps/web/src/components/ChatView.tsx+42-22| ... | @@ -68,6 +68,7 @@ import { | ... | @@ -68,6 +68,7 @@ import { |
| 68 | projectScriptRuntimeEnv, | 68 | projectScriptRuntimeEnv, |
| 69 | resolveProjectScripts, | 69 | resolveProjectScripts, |
| 70 | } from "@t3tools/shared/projectScripts"; | 70 | } from "@t3tools/shared/projectScripts"; |
| 71 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 71 | import { truncate } from "@t3tools/shared/String"; | 72 | import { truncate } from "@t3tools/shared/String"; |
| 72 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | 73 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; |
| 73 | import { | 74 | import { |
| ... | @@ -304,7 +305,6 @@ import { | ... | @@ -304,7 +305,6 @@ import { |
| 304 | environmentServerConfigsAtom, | 305 | environmentServerConfigsAtom, |
| 305 | primaryServerAvailableEditorsAtom, | 306 | primaryServerAvailableEditorsAtom, |
| 306 | primaryServerKeybindingsAtom, | 307 | primaryServerKeybindingsAtom, |
| 307 | primaryServerSettingsAtom, | ||
| 308 | serverEnvironment, | 308 | serverEnvironment, |
| 309 | } from "../state/server"; | 309 | } from "../state/server"; |
| 310 | import { terminalEnvironment } from "../state/terminal"; | 310 | import { terminalEnvironment } from "../state/terminal"; |
| ... | @@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1516 | }, [routeKind, routeThreadRef, routeThreadState]); | 1516 | }, [routeKind, routeThreadRef, routeThreadState]); |
| 1517 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 1517 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1518 | const settings = useEnvironmentSettings(environmentId); | 1518 | const settings = useEnvironmentSettings(environmentId); |
| 1519 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); | ||
| 1520 | const setStickyComposerModelSelection = useComposerDraftStore( | 1519 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 1521 | (store) => store.setStickyModelSelection, | 1520 | (store) => store.setStickyModelSelection, |
| 1522 | ); | 1521 | ); |
| ... | @@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) { |
| 1802 | ? buildLocalDraftThread( | 1801 | ? buildLocalDraftThread( |
| 1803 | threadId, | 1802 | threadId, |
| 1804 | draftThread, | 1803 | draftThread, |
| 1805 | fallbackDraftProject?.defaultModelSelection ?? | 1804 | resolveProjectSettings( |
| 1806 | settings.defaultModelSelection ?? | 1805 | settings, |
| 1807 | NO_PROVIDER_MODEL_SELECTION, | 1806 | fallbackDraftProject?.id ?? null, |
| 1807 | fallbackDraftProject ?? undefined, | ||
| 1808 | ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, | ||
| 1808 | ) | 1809 | ) |
| 1809 | : undefined, | 1810 | : undefined, |
| 1810 | [ | 1811 | [draftThread, fallbackDraftProject, settings, threadId], |
| 1811 | draftThread, | ||
| 1812 | fallbackDraftProject?.defaultModelSelection, | ||
| 1813 | settings.defaultModelSelection, | ||
| 1814 | threadId, | ||
| 1815 | ], | ||
| 1816 | ); | 1812 | ); |
| 1817 | // Promotion is data-driven: the draft route keeps rendering while the | 1813 | // Promotion is data-driven: the draft route keeps rendering while the |
| 1818 | // server thread (same pre-allocated ref) starts, so live state must not | 1814 | // server thread (same pre-allocated ref) starts, so live state must not |
| ... | @@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) { |
| 2033 | [activeThread?.environmentId, activeThread?.projectId], | 2029 | [activeThread?.environmentId, activeThread?.projectId], |
| 2034 | ); | 2030 | ); |
| 2035 | const activeProject = useProject(activeProjectRef); | 2031 | const activeProject = useProject(activeProjectRef); |
| 2032 | // Environment settings with the active project's overrides applied. | ||
| 2033 | const activeProjectSettings = useMemo( | ||
| 2034 | () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined), | ||
| 2035 | [activeProject, settings], | ||
| 2036 | ); | ||
| 2036 | const activeProjectScripts = useMemo( | 2037 | const activeProjectScripts = useMemo( |
| 2037 | () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), | 2038 | () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), |
| 2038 | [activeProject, settings], | 2039 | [activeProject, settings], |
| 2039 | ); | 2040 | ); |
| 2040 | const activeProjectDefaultModelSelection = | 2041 | const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; |
| 2041 | activeProject?.defaultModelSelection ?? settings.defaultModelSelection; | ||
| 2042 | const handleNewThreadInActiveProject = useCallback(() => { | 2042 | const handleNewThreadInActiveProject = useCallback(() => { |
| 2043 | startNewThreadForProject(activeProjectRef, handleNewThread); | 2043 | startNewThreadForProject(activeProjectRef, handleNewThread); |
| 2044 | }, [activeProjectRef, handleNewThread]); | 2044 | }, [activeProjectRef, handleNewThread]); |
| ... | @@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) { |
| 3945 | ], | 3945 | ], |
| 3946 | ); | 3946 | ); |
| 3947 | 3947 | ||
| 3948 | const supportsProjectSettingsOverrides = | ||
| 3949 | environmentById.get(environmentId)?.serverConfig?.environment.capabilities | ||
| 3950 | .projectSettingsOverrides === true; | ||
| 3948 | const persistProjectScripts = useCallback( | 3951 | const persistProjectScripts = useCallback( |
| 3949 | async (input: { | 3952 | async (input: { |
| 3950 | projectId: ProjectId; | 3953 | projectId: ProjectId; |
| ... | @@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) { |
| 3958 | await updateProjectScriptSettings({ | 3961 | await updateProjectScriptSettings({ |
| 3959 | environmentId, | 3962 | environmentId, |
| 3960 | input: { | 3963 | input: { |
| 3961 | patch: { | 3964 | // The canonical key on servers that understand it; the legacy |
| 3962 | projectScriptOverrides: { | 3965 | // per-project map is still translated on older ones. |
| 3963 | [input.projectId]: input.nextScripts, | 3966 | patch: supportsProjectSettingsOverrides |
| 3964 | }, | 3967 | ? { |
| 3965 | }, | 3968 | projectSettingsOverrides: { |
| 3969 | [input.projectId]: { | ||
| 3970 | ...settings.projectSettingsOverrides[input.projectId], | ||
| 3971 | defaultProjectScripts: input.nextScripts, | ||
| 3972 | }, | ||
| 3973 | }, | ||
| 3974 | } | ||
| 3975 | : { | ||
| 3976 | projectScriptOverrides: { | ||
| 3977 | [input.projectId]: input.nextScripts, | ||
| 3978 | }, | ||
| 3979 | }, | ||
| 3966 | }, | 3980 | }, |
| 3967 | }), | 3981 | }), |
| 3968 | () => undefined, | 3982 | () => undefined, |
| ... | @@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) { |
| 3987 | } | 4001 | } |
| 3988 | return updateResult; | 4002 | return updateResult; |
| 3989 | }, | 4003 | }, |
| 3990 | [environmentId, updateProjectScriptSettings, upsertKeybinding], | 4004 | [ |
| 4005 | environmentId, | ||
| 4006 | settings.projectSettingsOverrides, | ||
| 4007 | supportsProjectSettingsOverrides, | ||
| 4008 | updateProjectScriptSettings, | ||
| 4009 | upsertKeybinding, | ||
| 4010 | ], | ||
| 3991 | ); | 4011 | ); |
| 3992 | const saveProjectScript = useCallback( | 4012 | const saveProjectScript = useCallback( |
| 3993 | async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => { | 4013 | async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => { |
| ... | @@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5396 | ? (draftThread?.startFromOrigin ?? false) | 5416 | ? (draftThread?.startFromOrigin ?? false) |
| 5397 | : canOverrideServerThreadEnvMode | 5417 | : canOverrideServerThreadEnvMode |
| 5398 | ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? | 5418 | ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? |
| 5399 | primaryServerSettings.newWorktreesStartFromOrigin) | 5419 | activeProjectSettings.settings.newWorktreesStartFromOrigin) |
| 5400 | : false; | 5420 | : false; |
| 5401 | const sendEnvMode = resolveSendEnvMode({ | 5421 | const sendEnvMode = resolveSendEnvMode({ |
| 5402 | requestedEnvMode: envMode, | 5422 | requestedEnvMode: envMode, |
| ... | @@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8045 | envMode: mode, | 8065 | envMode: mode, |
| 8046 | startFromOrigin: resolveNewDraftStartFromOrigin({ | 8066 | startFromOrigin: resolveNewDraftStartFromOrigin({ |
| 8047 | envMode: mode, | 8067 | envMode: mode, |
| 8048 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | 8068 | newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, |
| 8049 | }), | 8069 | }), |
| 8050 | ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), | 8070 | ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), |
| 8051 | }); | 8071 | }); |
| ... | @@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8057 | composerDraftTarget, | 8077 | composerDraftTarget, |
| 8058 | draftThread?.worktreePath, | 8078 | draftThread?.worktreePath, |
| 8059 | isLocalDraftThread, | 8079 | isLocalDraftThread, |
| 8060 | primaryServerSettings.newWorktreesStartFromOrigin, | 8080 | activeProjectSettings.settings.newWorktreesStartFromOrigin, |
| 8061 | setPendingServerThreadEnvMode, | 8081 | setPendingServerThreadEnvMode, |
| 8062 | scheduleComposerFocus, | 8082 | scheduleComposerFocus, |
| 8063 | setDraftThreadContext, | 8083 | setDraftThreadContext, |
apps/web/src/components/CommandPalette.tsx+1-4| ... | @@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: { |
| 1814 | }, | 1814 | }, |
| 1815 | }); | 1815 | }); |
| 1816 | 1816 | ||
| 1817 | // There is no projects listing page; the action targets the contextual | 1817 | // Target the active thread or draft's project, falling back to the first sidebar group. |
| 1818 | // project (active thread/draft, falling back to the first sidebar group). | ||
| 1819 | const contextualProjectGroup = | 1818 | const contextualProjectGroup = |
| 1820 | (contextualProjectRef | 1819 | (contextualProjectRef |
| 1821 | ? projectGroupByTargetKey.get( | 1820 | ? projectGroupByTargetKey.get( |
| ... | @@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: { |
| 1867 | run: async () => { | 1866 | run: async () => { |
| 1868 | await navigate({ | 1867 | await navigate({ |
| 1869 | to: item.to, | 1868 | to: item.to, |
| 1870 | search: (previous) => | ||
| 1871 | item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, | ||
| 1872 | hash: item.targetId ?? item.id, | 1869 | hash: item.targetId ?? item.id, |
| 1873 | replace: pathname === item.to, | 1870 | replace: pathname === item.to, |
| 1874 | hashScrollIntoView: false, | 1871 | hashScrollIntoView: false, |
apps/web/src/components/chat/DraftHeroHeadline.tsx+8-5| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | MenuTrigger, | 27 | MenuTrigger, |
| 28 | } from "../ui/menu"; | 28 | } from "../ui/menu"; |
| 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 30 | 31 | ||
| 31 | interface DraftHeroHeadlineProps { | 32 | interface DraftHeroHeadlineProps { |
| 32 | readonly draftId: DraftId | null; | 33 | readonly draftId: DraftId | null; |
| ... | @@ -150,11 +151,13 @@ export function DraftHeroHeadline({ | ... | @@ -150,11 +151,13 @@ export function DraftHeroHeadline({ |
| 150 | ); | 151 | ); |
| 151 | if (!hasExplicitComposerModelSelection(currentDraft)) { | 152 | if (!hasExplicitComposerModelSelection(currentDraft)) { |
| 152 | applyStickyState(draftId); | 153 | applyStickyState(draftId); |
| 153 | const defaultModelSelection = | 154 | const environmentSettings = environments.find( |
| 154 | project.defaultModelSelection ?? | 155 | (environment) => environment.environmentId === project.environmentId, |
| 155 | environments.find( | 156 | )?.serverConfig?.settings; |
| 156 | (environment) => environment.environmentId === project.environmentId, | 157 | const defaultModelSelection = environmentSettings |
| 157 | )?.serverConfig?.settings.defaultModelSelection; | 158 | ? resolveProjectSettings(environmentSettings, project.id, project).settings |
| 159 | .defaultModelSelection | ||
| 160 | : project.defaultModelSelection; | ||
| 158 | if (defaultModelSelection) { | 161 | if (defaultModelSelection) { |
| 159 | setModelSelection(draftId, defaultModelSelection, { | 162 | setModelSelection(draftId, defaultModelSelection, { |
| 160 | replaceOptions: true, | 163 | replaceOptions: true, |
apps/web/src/components/chat/ProviderModelPicker.test.tsx+15| ... | @@ -34,6 +34,7 @@ function renderPicker(input: { | ... | @@ -34,6 +34,7 @@ function renderPicker(input: { |
| 34 | model: string; | 34 | model: string; |
| 35 | options: ReadonlyArray<ModelEsque>; | 35 | options: ReadonlyArray<ModelEsque>; |
| 36 | includeEntry?: boolean; | 36 | includeEntry?: boolean; |
| 37 | triggerLabel?: string; | ||
| 37 | }) { | 38 | }) { |
| 38 | const instanceId = ProviderInstanceId.make(input.instanceId); | 39 | const instanceId = ProviderInstanceId.make(input.instanceId); |
| 39 | const entry = providerEntry(input.instanceId, input.driver); | 40 | const entry = providerEntry(input.instanceId, input.driver); |
| ... | @@ -45,11 +46,25 @@ function renderPicker(input: { | ... | @@ -45,11 +46,25 @@ function renderPicker(input: { |
| 45 | instanceEntries={input.includeEntry === false ? [] : [entry]} | 46 | instanceEntries={input.includeEntry === false ? [] : [entry]} |
| 46 | modelOptionsByInstance={new Map([[instanceId, input.options]])} | 47 | modelOptionsByInstance={new Map([[instanceId, input.options]])} |
| 47 | onInstanceModelChange={() => {}} | 48 | onInstanceModelChange={() => {}} |
| 49 | {...(input.triggerLabel ? { triggerLabel: input.triggerLabel } : {})} | ||
| 48 | />, | 50 | />, |
| 49 | ); | 51 | ); |
| 50 | } | 52 | } |
| 51 | 53 | ||
| 52 | describe("ProviderModelPicker", () => { | 54 | describe("ProviderModelPicker", () => { |
| 55 | it("shows a neutral aggregate value without a representative model or availability badge", () => { | ||
| 56 | const markup = renderPicker({ | ||
| 57 | instanceId: "codex_personal", | ||
| 58 | driver: "codex", | ||
| 59 | model: "gpt-5", | ||
| 60 | options: [{ slug: "gpt-5", name: "GPT 5", isUnavailable: true }], | ||
| 61 | triggerLabel: "Mixed values", | ||
| 62 | }); | ||
| 63 | expect(markup).toContain("Mixed values"); | ||
| 64 | expect(markup).not.toContain("GPT 5"); | ||
| 65 | expect(markup).not.toContain("Unavailable"); | ||
| 66 | }); | ||
| 67 | |||
| 53 | it.each(["", ANTIGRAVITY_DEFAULT_MODEL])( | 68 | it.each(["", ANTIGRAVITY_DEFAULT_MODEL])( |
| 54 | "shows a choice prompt before Antigravity has an account catalog for %s", | 69 | "shows a choice prompt before Antigravity has an account catalog for %s", |
| 55 | (model) => { | 70 | (model) => { |
apps/web/src/components/chat/ProviderModelPicker.tsx+6-4| ... | @@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 48 | open?: boolean; | 48 | open?: boolean; |
| 49 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; | 49 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 50 | triggerClassName?: string; | 50 | triggerClassName?: string; |
| 51 | /** Aggregate settings can show a neutral value without claiming one provider is selected. */ | ||
| 52 | triggerLabel?: string; | ||
| 51 | triggerAriaLabel?: string; | 53 | triggerAriaLabel?: string; |
| 52 | onOpenChange?: (open: boolean) => void; | 54 | onOpenChange?: (open: boolean) => void; |
| 53 | onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; | 55 | onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; |
| ... | @@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 181 | <span | 183 | <span |
| 182 | className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} | 184 | className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} |
| 183 | > | 185 | > |
| 184 | {activeEntry ? ( | 186 | {activeEntry && props.triggerLabel === undefined ? ( |
| 185 | <ProviderInstanceIcon | 187 | <ProviderInstanceIcon |
| 186 | driverKind={activeEntry.driverKind} | 188 | driverKind={activeEntry.driverKind} |
| 187 | displayName={activeEntry.displayName} | 189 | displayName={activeEntry.displayName} |
| ... | @@ -205,11 +207,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -205,11 +207,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 205 | /> | 207 | /> |
| 206 | } | 208 | } |
| 207 | > | 209 | > |
| 208 | {triggerTitle} | 210 | {props.triggerLabel ?? triggerTitle} |
| 209 | </TooltipTrigger> | 211 | </TooltipTrigger> |
| 210 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> | 212 | <TooltipPopup side="top">{props.triggerLabel ?? triggerLabel}</TooltipPopup> |
| 211 | </Tooltip> | 213 | </Tooltip> |
| 212 | {selectedModel?.isUnavailable ? ( | 214 | {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( |
| 213 | <Badge variant="outline" size="sm"> | 215 | <Badge variant="outline" size="sm"> |
| 214 | Unavailable | 216 | Unavailable |
| 215 | </Badge> | 217 | </Badge> |
apps/web/src/components/projectScriptEditor.test.tsx created+245| ... | @@ -0,0 +1,245 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { act, StrictMode, type ReactNode } from "react"; | ||
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | vi.mock("./ui/dialog", () => ({ | ||
| 8 | Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null), | ||
| 9 | DialogDescription: "p", | ||
| 10 | DialogFooter: "footer", | ||
| 11 | DialogHeader: "header", | ||
| 12 | DialogPanel: "section", | ||
| 13 | DialogPopup: "section", | ||
| 14 | DialogTitle: "h2", | ||
| 15 | })); | ||
| 16 | vi.mock("./ui/alert-dialog", () => ({ | ||
| 17 | AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) => | ||
| 18 | open ? children : null, | ||
| 19 | AlertDialogClose: "button", | ||
| 20 | AlertDialogDescription: "p", | ||
| 21 | AlertDialogFooter: "footer", | ||
| 22 | AlertDialogHeader: "header", | ||
| 23 | AlertDialogPopup: "section", | ||
| 24 | AlertDialogTitle: "h2", | ||
| 25 | })); | ||
| 26 | vi.mock("./ui/button", () => ({ Button: "button" })); | ||
| 27 | vi.mock("./ui/input", () => ({ Input: "input" })); | ||
| 28 | vi.mock("./ui/label", () => ({ Label: "label" })); | ||
| 29 | vi.mock("./ui/popover", () => ({ | ||
| 30 | Popover: ({ children }: { children: ReactNode }) => children, | ||
| 31 | PopoverPopup: () => null, | ||
| 32 | PopoverTrigger: "button", | ||
| 33 | })); | ||
| 34 | vi.mock("./ui/switch", () => ({ Switch: "input" })); | ||
| 35 | vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); | ||
| 36 | |||
| 37 | import { | ||
| 38 | EMPTY_PROJECT_SCRIPT_INPUT, | ||
| 39 | ProjectScriptEditorDialog, | ||
| 40 | type ProjectScriptActionResult, | ||
| 41 | type ProjectScriptEditorRequest, | ||
| 42 | } from "./projectScriptEditor"; | ||
| 43 | |||
| 44 | const onSubmit = vi.fn<Parameters<typeof ProjectScriptEditorDialog>[0]["onSubmit"]>(); | ||
| 45 | const onClose = vi.fn(); | ||
| 46 | const onDelete = vi.fn(); | ||
| 47 | let renderer: ReactTestRenderer | null; | ||
| 48 | |||
| 49 | function request(name: string, error?: string): ProjectScriptEditorRequest { | ||
| 50 | return { | ||
| 51 | scriptId: name, | ||
| 52 | initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` }, | ||
| 53 | ...(error === undefined ? {} : { error }), | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | function editor(nextRequest: ProjectScriptEditorRequest) { | ||
| 58 | return ( | ||
| 59 | <StrictMode> | ||
| 60 | <ProjectScriptEditorDialog | ||
| 61 | request={nextRequest} | ||
| 62 | scripts={[]} | ||
| 63 | onSubmit={onSubmit} | ||
| 64 | onClose={onClose} | ||
| 65 | onDelete={onDelete} | ||
| 66 | /> | ||
| 67 | </StrictMode> | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function open(nextRequest: ProjectScriptEditorRequest) { | ||
| 72 | act(() => { | ||
| 73 | if (renderer) renderer.update(editor(nextRequest)); | ||
| 74 | else renderer = create(editor(nextRequest)); | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | |||
| 78 | function submit(): Promise<void> { | ||
| 79 | return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }); | ||
| 80 | } | ||
| 81 | |||
| 82 | function saveButton() { | ||
| 83 | return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!; | ||
| 84 | } | ||
| 85 | |||
| 86 | function deferredSave() { | ||
| 87 | let resolve!: (result: ProjectScriptActionResult) => void; | ||
| 88 | let reject!: (error: Error) => void; | ||
| 89 | const promise = new Promise<ProjectScriptActionResult>((resolveResult, rejectResult) => { | ||
| 90 | resolve = resolveResult; | ||
| 91 | reject = rejectResult; | ||
| 92 | }); | ||
| 93 | return { promise, resolve, reject }; | ||
| 94 | } | ||
| 95 | |||
| 96 | beforeEach(() => { | ||
| 97 | renderer = null; | ||
| 98 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 99 | onSubmit.mockReset(); | ||
| 100 | onClose.mockReset(); | ||
| 101 | onDelete.mockReset(); | ||
| 102 | }); | ||
| 103 | |||
| 104 | afterEach(async () => { | ||
| 105 | await act(async () => renderer?.unmount()); | ||
| 106 | vi.unstubAllGlobals(); | ||
| 107 | }); | ||
| 108 | |||
| 109 | describe("project action editor save lifecycle", () => { | ||
| 110 | it("blocks repeated submits and edits until the current save completes", async () => { | ||
| 111 | const save = deferredSave(); | ||
| 112 | onSubmit.mockReturnValue(save.promise); | ||
| 113 | open(request("build")); | ||
| 114 | |||
| 115 | let completion!: Promise<void>; | ||
| 116 | act(() => { | ||
| 117 | completion = submit(); | ||
| 118 | void submit(); | ||
| 119 | }); | ||
| 120 | |||
| 121 | expect(onSubmit).toHaveBeenCalledTimes(1); | ||
| 122 | expect(saveButton().props.disabled).toBe(true); | ||
| 123 | expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true); | ||
| 124 | const cancel = renderer!.root | ||
| 125 | .findAllByType("button") | ||
| 126 | .find((button) => button.children.includes("Cancel"))!; | ||
| 127 | expect(cancel.props.disabled).not.toBe(true); | ||
| 128 | |||
| 129 | await act(async () => { | ||
| 130 | save.resolve(AsyncResult.success(undefined)); | ||
| 131 | await completion; | ||
| 132 | }); | ||
| 133 | expect(onClose).toHaveBeenCalledTimes(1); | ||
| 134 | }); | ||
| 135 | |||
| 136 | it("does not close a replacement request or release its in-flight save", async () => { | ||
| 137 | const first = deferredSave(); | ||
| 138 | const second = deferredSave(); | ||
| 139 | onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); | ||
| 140 | open(request("build")); | ||
| 141 | let firstCompletion!: Promise<void>; | ||
| 142 | act(() => { | ||
| 143 | firstCompletion = submit(); | ||
| 144 | }); | ||
| 145 | |||
| 146 | open(request("test")); | ||
| 147 | expect(saveButton().props.disabled).toBe(false); | ||
| 148 | expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test"); | ||
| 149 | let secondCompletion!: Promise<void>; | ||
| 150 | act(() => { | ||
| 151 | secondCompletion = submit(); | ||
| 152 | }); | ||
| 153 | |||
| 154 | await act(async () => { | ||
| 155 | first.resolve(AsyncResult.success(undefined)); | ||
| 156 | await firstCompletion; | ||
| 157 | }); | ||
| 158 | expect(onClose).not.toHaveBeenCalled(); | ||
| 159 | expect(saveButton().props.disabled).toBe(true); | ||
| 160 | |||
| 161 | await act(async () => { | ||
| 162 | second.resolve(AsyncResult.success(undefined)); | ||
| 163 | await secondCompletion; | ||
| 164 | }); | ||
| 165 | expect(onClose).toHaveBeenCalledTimes(1); | ||
| 166 | expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it.each(["failure", "rejection"] as const)( | ||
| 170 | "ignores a stale %s after the request changes", | ||
| 171 | async (outcome) => { | ||
| 172 | const save = deferredSave(); | ||
| 173 | onSubmit.mockReturnValue(save.promise); | ||
| 174 | open(request("build")); | ||
| 175 | let completion!: Promise<void>; | ||
| 176 | act(() => { | ||
| 177 | completion = submit(); | ||
| 178 | }); | ||
| 179 | |||
| 180 | open(request("test", "New request error")); | ||
| 181 | await act(async () => { | ||
| 182 | if (outcome === "failure") | ||
| 183 | save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); | ||
| 184 | else save.reject(new Error("Old save error")); | ||
| 185 | await completion; | ||
| 186 | }); | ||
| 187 | |||
| 188 | const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); | ||
| 189 | expect(messages).toContain("New request error"); | ||
| 190 | expect(messages).not.toContain("Old save error"); | ||
| 191 | expect(saveButton().props.disabled).toBe(false); | ||
| 192 | expect(onClose).not.toHaveBeenCalled(); | ||
| 193 | }, | ||
| 194 | ); | ||
| 195 | |||
| 196 | it("shows a current save error and allows retry", async () => { | ||
| 197 | onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); | ||
| 198 | onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); | ||
| 199 | open(request("build")); | ||
| 200 | |||
| 201 | await act(async () => { | ||
| 202 | await submit(); | ||
| 203 | }); | ||
| 204 | expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain( | ||
| 205 | "Save failed", | ||
| 206 | ); | ||
| 207 | expect(saveButton().props.disabled).toBe(false); | ||
| 208 | expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false); | ||
| 209 | expect(onClose).not.toHaveBeenCalled(); | ||
| 210 | |||
| 211 | await act(async () => { | ||
| 212 | await submit(); | ||
| 213 | }); | ||
| 214 | expect(onSubmit).toHaveBeenCalledTimes(2); | ||
| 215 | expect(onClose).toHaveBeenCalledTimes(1); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { | ||
| 219 | const save = deferredSave(); | ||
| 220 | onSubmit.mockReturnValue(save.promise); | ||
| 221 | open(request("build")); | ||
| 222 | let completion!: Promise<void>; | ||
| 223 | act(() => { | ||
| 224 | completion = submit(); | ||
| 225 | }); | ||
| 226 | |||
| 227 | act(() => { | ||
| 228 | if (exit === "cancel") { | ||
| 229 | renderer!.root | ||
| 230 | .findAllByType("button") | ||
| 231 | .find((button) => button.children.includes("Cancel"))! | ||
| 232 | .props.onClick(); | ||
| 233 | } else { | ||
| 234 | renderer!.unmount(); | ||
| 235 | renderer = null; | ||
| 236 | } | ||
| 237 | }); | ||
| 238 | onClose.mockClear(); | ||
| 239 | await act(async () => { | ||
| 240 | save.resolve(AsyncResult.success(undefined)); | ||
| 241 | await completion; | ||
| 242 | }); | ||
| 243 | expect(onClose).not.toHaveBeenCalled(); | ||
| 244 | }); | ||
| 245 | }); | ||
apps/web/src/components/projectScriptEditor.tsx+165-118| ... | @@ -16,7 +16,14 @@ import { | ... | @@ -16,7 +16,14 @@ import { |
| 16 | PlayIcon, | 16 | PlayIcon, |
| 17 | WrenchIcon, | 17 | WrenchIcon, |
| 18 | } from "lucide-react"; | 18 | } from "lucide-react"; |
| 19 | import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; | 19 | import React, { |
| 20 | type FormEvent, | ||
| 21 | type KeyboardEvent, | ||
| 22 | useEffect, | ||
| 23 | useLayoutEffect, | ||
| 24 | useRef, | ||
| 25 | useState, | ||
| 26 | } from "react"; | ||
| 20 | 27 | ||
| 21 | import { | 28 | import { |
| 22 | keybindingValueForCommand, | 29 | keybindingValueForCommand, |
| ... | @@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({ | ... | @@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({ |
| 156 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); | 163 | const [autoOpenPreview, setAutoOpenPreview] = useState(false); |
| 157 | const [validationError, setValidationError] = useState<string | null>(null); | 164 | const [validationError, setValidationError] = useState<string | null>(null); |
| 158 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | 165 | const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); |
| 166 | const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null); | ||
| 167 | const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); | ||
| 159 | 168 | ||
| 160 | const isOpen = request !== null; | 169 | const isOpen = request !== null; |
| 161 | const isEditing = request?.scriptId != null; | 170 | const isEditing = request?.scriptId != null; |
| 171 | const isSaving = request !== null && savingRequest === request; | ||
| 172 | |||
| 173 | // A save completion must not affect a replacement request or an unmounted editor. | ||
| 174 | useLayoutEffect( | ||
| 175 | () => () => { | ||
| 176 | if (pendingSubmissionRef.current?.request === request) { | ||
| 177 | pendingSubmissionRef.current = null; | ||
| 178 | } | ||
| 179 | }, | ||
| 180 | [request], | ||
| 181 | ); | ||
| 162 | 182 | ||
| 163 | // Hydrate the form whenever a new request opens the dialog. | 183 | // Hydrate the form whenever a new request opens the dialog. |
| 164 | useEffect(() => { | 184 | useEffect(() => { |
| ... | @@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({ | ... | @@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({ |
| 172 | setPreviewUrl(request.initial.previewUrl ?? ""); | 192 | setPreviewUrl(request.initial.previewUrl ?? ""); |
| 173 | setAutoOpenPreview(request.initial.autoOpenPreview); | 193 | setAutoOpenPreview(request.initial.autoOpenPreview); |
| 174 | setValidationError(request.error ?? null); | 194 | setValidationError(request.error ?? null); |
| 195 | setSavingRequest(null); | ||
| 175 | }, [request]); | 196 | }, [request]); |
| 176 | 197 | ||
| 198 | const close = () => { | ||
| 199 | pendingSubmissionRef.current = null; | ||
| 200 | setSavingRequest(null); | ||
| 201 | setIconPickerOpen(false); | ||
| 202 | onClose(); | ||
| 203 | }; | ||
| 204 | |||
| 177 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | 205 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { |
| 178 | if (event.key === "Tab") return; | 206 | if (event.key === "Tab") return; |
| 179 | event.preventDefault(); | 207 | event.preventDefault(); |
| ... | @@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({ | ... | @@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({ |
| 188 | 216 | ||
| 189 | const submit = async (event: FormEvent) => { | 217 | const submit = async (event: FormEvent) => { |
| 190 | event.preventDefault(); | 218 | event.preventDefault(); |
| 191 | if (!request) return; | 219 | if (!request || pendingSubmissionRef.current !== null) return; |
| 192 | const trimmedName = name.trim(); | 220 | const trimmedName = name.trim(); |
| 193 | const trimmedCommand = command.trim(); | 221 | const trimmedCommand = command.trim(); |
| 194 | if (trimmedName.length === 0) { | 222 | if (trimmedName.length === 0) { |
| ... | @@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({ | ... | @@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({ |
| 228 | return; | 256 | return; |
| 229 | } | 257 | } |
| 230 | 258 | ||
| 231 | const result = await onSubmit(request.scriptId, payload); | 259 | const submission = { request }; |
| 232 | if (result._tag === "Failure") { | 260 | pendingSubmissionRef.current = submission; |
| 233 | if (!isAtomCommandInterrupted(result)) { | 261 | setSavingRequest(request); |
| 234 | const error = squashAtomCommandFailure(result); | 262 | setIconPickerOpen(false); |
| 263 | try { | ||
| 264 | const result = await onSubmit(request.scriptId, payload); | ||
| 265 | if (pendingSubmissionRef.current === submission) { | ||
| 266 | if (result._tag === "Failure") { | ||
| 267 | if (!isAtomCommandInterrupted(result)) { | ||
| 268 | const error = squashAtomCommandFailure(result); | ||
| 269 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | ||
| 270 | } | ||
| 271 | } else { | ||
| 272 | close(); | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } catch (error) { | ||
| 276 | if (pendingSubmissionRef.current === submission) { | ||
| 235 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | 277 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); |
| 236 | } | 278 | } |
| 237 | return; | ||
| 238 | } | 279 | } |
| 239 | setIconPickerOpen(false); | 280 | if (pendingSubmissionRef.current === submission) { |
| 240 | onClose(); | 281 | pendingSubmissionRef.current = null; |
| 282 | setSavingRequest(null); | ||
| 283 | } | ||
| 241 | }; | 284 | }; |
| 242 | 285 | ||
| 243 | return ( | 286 | return ( |
| ... | @@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({ | ... | @@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({ |
| 246 | open={isOpen} | 289 | open={isOpen} |
| 247 | onOpenChange={(open) => { | 290 | onOpenChange={(open) => { |
| 248 | if (!open) { | 291 | if (!open) { |
| 249 | setIconPickerOpen(false); | 292 | close(); |
| 250 | onClose(); | ||
| 251 | } | 293 | } |
| 252 | }} | 294 | }} |
| 253 | > | 295 | > |
| ... | @@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({ | ... | @@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({ |
| 259 | </DialogDescription> | 301 | </DialogDescription> |
| 260 | </DialogHeader> | 302 | </DialogHeader> |
| 261 | <DialogPanel> | 303 | <DialogPanel> |
| 262 | <form id={formId} className="space-y-4" onSubmit={submit}> | 304 | <form id={formId} onSubmit={submit}> |
| 263 | <div className="space-y-1.5"> | 305 | <fieldset className="space-y-4" disabled={isSaving}> |
| 264 | <Label htmlFor="script-name">Name</Label> | 306 | <div className="space-y-1.5"> |
| 265 | <div className="flex items-center gap-2"> | 307 | <Label htmlFor="script-name">Name</Label> |
| 266 | <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | 308 | <div className="flex items-center gap-2"> |
| 267 | <PopoverTrigger | 309 | <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> |
| 268 | render={ | 310 | <PopoverTrigger |
| 269 | <Button | 311 | render={ |
| 270 | type="button" | 312 | <Button |
| 271 | variant="outline" | 313 | type="button" |
| 272 | className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none" | 314 | variant="outline" |
| 273 | aria-label="Choose icon" | 315 | className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:bg-white/[0.035] dark:data-pressed:before:shadow-none" |
| 274 | /> | 316 | aria-label="Choose icon" |
| 275 | } | 317 | /> |
| 276 | > | 318 | } |
| 277 | <ScriptIcon icon={icon} className="size-4.5" /> | 319 | > |
| 278 | </PopoverTrigger> | 320 | <ScriptIcon icon={icon} className="size-4.5" /> |
| 279 | <PopoverPopup align="start"> | 321 | </PopoverTrigger> |
| 280 | <div className="grid grid-cols-3 gap-2"> | 322 | <PopoverPopup align="start"> |
| 281 | {SCRIPT_ICONS.map((entry) => { | 323 | <div className="grid grid-cols-3 gap-2"> |
| 282 | const isSelected = entry.id === icon; | 324 | {SCRIPT_ICONS.map((entry) => { |
| 283 | return ( | 325 | const isSelected = entry.id === icon; |
| 284 | <button | 326 | return ( |
| 285 | key={entry.id} | 327 | <button |
| 286 | type="button" | 328 | key={entry.id} |
| 287 | className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ | 329 | type="button" |
| 288 | isSelected | 330 | className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ |
| 289 | ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" | 331 | isSelected |
| 290 | : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" | 332 | ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" |
| 291 | }`} | 333 | : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" |
| 292 | onClick={() => { | 334 | }`} |
| 293 | setIcon(entry.id); | 335 | onClick={() => { |
| 294 | setIconPickerOpen(false); | 336 | setIcon(entry.id); |
| 295 | }} | 337 | setIconPickerOpen(false); |
| 296 | > | 338 | }} |
| 297 | <ScriptIcon icon={entry.id} className="size-4" /> | 339 | > |
| 298 | <span>{entry.label}</span> | 340 | <ScriptIcon icon={entry.id} className="size-4" /> |
| 299 | </button> | 341 | <span>{entry.label}</span> |
| 300 | ); | 342 | </button> |
| 301 | })} | 343 | ); |
| 302 | </div> | 344 | })} |
| 303 | </PopoverPopup> | 345 | </div> |
| 304 | </Popover> | 346 | </PopoverPopup> |
| 347 | </Popover> | ||
| 348 | <Input | ||
| 349 | id="script-name" | ||
| 350 | autoFocus | ||
| 351 | placeholder="Test" | ||
| 352 | value={name} | ||
| 353 | onChange={(event) => setName(event.target.value)} | ||
| 354 | /> | ||
| 355 | </div> | ||
| 356 | </div> | ||
| 357 | <div className="space-y-1.5"> | ||
| 358 | <Label htmlFor="script-keybinding">Keybinding</Label> | ||
| 305 | <Input | 359 | <Input |
| 306 | id="script-name" | 360 | id="script-keybinding" |
| 307 | autoFocus | 361 | placeholder="Press shortcut" |
| 308 | placeholder="Test" | 362 | value={keybinding} |
| 309 | value={name} | 363 | readOnly |
| 310 | onChange={(event) => setName(event.target.value)} | 364 | onKeyDown={captureKeybinding} |
| 311 | /> | 365 | /> |
| 366 | <p className="text-xs text-muted-foreground"> | ||
| 367 | Press a shortcut. Use <code>Backspace</code> to clear. Shortcuts are | ||
| 368 | environment-wide. Projects using the same action share its shortcut. | ||
| 369 | </p> | ||
| 312 | </div> | 370 | </div> |
| 313 | </div> | 371 | <div className="space-y-1.5"> |
| 314 | <div className="space-y-1.5"> | 372 | <Label htmlFor="script-command">Command</Label> |
| 315 | <Label htmlFor="script-keybinding">Keybinding</Label> | 373 | <Textarea |
| 316 | <Input | 374 | id="script-command" |
| 317 | id="script-keybinding" | 375 | placeholder="bun test" |
| 318 | placeholder="Press shortcut" | 376 | value={command} |
| 319 | value={keybinding} | 377 | onChange={(event) => setCommand(event.target.value)} |
| 320 | readOnly | 378 | /> |
| 321 | onKeyDown={captureKeybinding} | 379 | </div> |
| 322 | /> | 380 | <div className="space-y-1.5"> |
| 323 | <p className="text-xs text-muted-foreground"> | 381 | <Label htmlFor="script-preview-url">Preview URL (optional)</Label> |
| 324 | Press a shortcut. Use <code>Backspace</code> to clear. | 382 | <Input |
| 325 | </p> | 383 | id="script-preview-url" |
| 326 | </div> | 384 | placeholder="http://localhost:5173" |
| 327 | <div className="space-y-1.5"> | 385 | value={previewUrl} |
| 328 | <Label htmlFor="script-command">Command</Label> | 386 | onChange={(event) => setPreviewUrl(event.target.value)} |
| 329 | <Textarea | 387 | /> |
| 330 | id="script-command" | 388 | <p className="text-xs text-muted-foreground"> |
| 331 | placeholder="bun test" | 389 | Open this URL in the in-app preview when this action runs. |
| 332 | value={command} | 390 | </p> |
| 333 | onChange={(event) => setCommand(event.target.value)} | 391 | </div> |
| 334 | /> | 392 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]"> |
| 335 | </div> | 393 | <span>Run automatically on worktree creation</span> |
| 336 | <div className="space-y-1.5"> | 394 | <Switch |
| 337 | <Label htmlFor="script-preview-url">Preview URL (optional)</Label> | 395 | checked={runOnWorktreeCreate} |
| 338 | <Input | 396 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 339 | id="script-preview-url" | 397 | /> |
| 340 | placeholder="http://localhost:5173" | 398 | </label> |
| 341 | value={previewUrl} | 399 | <label |
| 342 | onChange={(event) => setPreviewUrl(event.target.value)} | 400 | className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${ |
| 343 | /> | 401 | previewUrl.trim().length === 0 ? "opacity-60" : "" |
| 344 | <p className="text-xs text-muted-foreground"> | 402 | }`} |
| 345 | Open this URL in the in-app preview when this action runs. | 403 | > |
| 346 | </p> | 404 | <span>Open preview automatically when this action runs</span> |
| 347 | </div> | 405 | <Switch |
| 348 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]"> | 406 | checked={autoOpenPreview} |
| 349 | <span>Run automatically on worktree creation</span> | 407 | disabled={previewUrl.trim().length === 0} |
| 350 | <Switch | 408 | onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} |
| 351 | checked={runOnWorktreeCreate} | 409 | /> |
| 352 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | 410 | </label> |
| 353 | /> | 411 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} |
| 354 | </label> | 412 | </fieldset> |
| 355 | <label | ||
| 356 | className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${ | ||
| 357 | previewUrl.trim().length === 0 ? "opacity-60" : "" | ||
| 358 | }`} | ||
| 359 | > | ||
| 360 | <span>Open preview automatically when this action runs</span> | ||
| 361 | <Switch | ||
| 362 | checked={autoOpenPreview} | ||
| 363 | disabled={previewUrl.trim().length === 0} | ||
| 364 | onCheckedChange={(checked) => setAutoOpenPreview(Boolean(checked))} | ||
| 365 | /> | ||
| 366 | </label> | ||
| 367 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} | ||
| 368 | </form> | 413 | </form> |
| 369 | </DialogPanel> | 414 | </DialogPanel> |
| 370 | <DialogFooter className="dark:border-transparent dark:bg-transparent"> | 415 | <DialogFooter className="dark:border-transparent dark:bg-transparent"> |
| ... | @@ -373,16 +418,17 @@ export function ProjectScriptEditorDialog({ | ... | @@ -373,16 +418,17 @@ export function ProjectScriptEditorDialog({ |
| 373 | type="button" | 418 | type="button" |
| 374 | variant="destructive-outline" | 419 | variant="destructive-outline" |
| 375 | className="mr-auto" | 420 | className="mr-auto" |
| 421 | disabled={isSaving} | ||
| 376 | onClick={() => setDeleteConfirmOpen(true)} | 422 | onClick={() => setDeleteConfirmOpen(true)} |
| 377 | > | 423 | > |
| 378 | Delete | 424 | Delete |
| 379 | </Button> | 425 | </Button> |
| 380 | )} | 426 | )} |
| 381 | <Button type="button" variant="outline" onClick={onClose}> | 427 | <Button type="button" variant="outline" onClick={close}> |
| 382 | Cancel | 428 | Cancel |
| 383 | </Button> | 429 | </Button> |
| 384 | <Button form={formId} type="submit"> | 430 | <Button form={formId} type="submit" disabled={isSaving}> |
| 385 | {isEditing ? "Save changes" : "Save action"} | 431 | {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"} |
| 386 | </Button> | 432 | </Button> |
| 387 | </DialogFooter> | 433 | </DialogFooter> |
| 388 | </DialogPopup> | 434 | </DialogPopup> |
| ... | @@ -398,10 +444,11 @@ export function ProjectScriptEditorDialog({ | ... | @@ -398,10 +444,11 @@ export function ProjectScriptEditorDialog({ |
| 398 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | 444 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 399 | <Button | 445 | <Button |
| 400 | variant="destructive" | 446 | variant="destructive" |
| 447 | disabled={isSaving} | ||
| 401 | onClick={() => { | 448 | onClick={() => { |
| 402 | if (!request?.scriptId) return; | 449 | if (!request?.scriptId) return; |
| 403 | setDeleteConfirmOpen(false); | 450 | setDeleteConfirmOpen(false); |
| 404 | onClose(); | 451 | close(); |
| 405 | onDelete(request.scriptId); | 452 | onDelete(request.scriptId); |
| 406 | }} | 453 | }} |
| 407 | > | 454 | > |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+20-7| ... | @@ -5,6 +5,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro | ... | @@ -5,6 +5,7 @@ import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/enviro |
| 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import { | 6 | import { |
| 7 | type EnvironmentId, | 7 | type EnvironmentId, |
| 8 | DEFAULT_SERVER_SETTINGS, | ||
| 8 | type PullRequestAction, | 9 | type PullRequestAction, |
| 9 | type PullRequestMergeMethod, | 10 | type PullRequestMergeMethod, |
| 10 | type PullRequestListEntry, | 11 | type PullRequestListEntry, |
| ... | @@ -13,6 +14,7 @@ import { | ... | @@ -13,6 +14,7 @@ import { |
| 13 | resolveEnvironmentMachineKind, | 14 | resolveEnvironmentMachineKind, |
| 14 | type ScopedThreadRef, | 15 | type ScopedThreadRef, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 16 | import { | 18 | import { |
| 17 | ArrowDownUpIcon, | 19 | ArrowDownUpIcon, |
| 18 | ArrowLeftIcon, | 20 | ArrowLeftIcon, |
| ... | @@ -55,8 +57,8 @@ import { | ... | @@ -55,8 +57,8 @@ import { |
| 55 | 57 | ||
| 56 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 58 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 57 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | 59 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 58 | import { useClientSettings } from "~/hooks/useSettings"; | ||
| 59 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 60 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 61 | import { useClientSettings } from "~/hooks/useSettings"; | ||
| 60 | import { | 62 | import { |
| 61 | deriveLogicalProjectKeyFromSettings, | 63 | deriveLogicalProjectKeyFromSettings, |
| 62 | derivePhysicalProjectKey, | 64 | derivePhysicalProjectKey, |
| ... | @@ -585,10 +587,19 @@ export function PullRequestDetailPanel({ | ... | @@ -585,10 +587,19 @@ export function PullRequestDetailPanel({ |
| 585 | }, [condensed]); | 587 | }, [condensed]); |
| 586 | const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); | 588 | const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); |
| 587 | const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); | 589 | const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); |
| 588 | const mergeMethodOverrides = useClientSettings( | 590 | // Server-side and per project, like every other project setting. The |
| 591 | // client-local per-project map from before still answers when the server | ||
| 592 | // has no value, so a choice made on an older release keeps applying until | ||
| 593 | // it is set (or reset) in Settings. | ||
| 594 | const legacyMergeMethodOverrides = useClientSettings( | ||
| 589 | (settings) => settings.pullRequestMergeMethodOverrides, | 595 | (settings) => settings.pullRequestMergeMethodOverrides, |
| 590 | ); | 596 | ); |
| 591 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 597 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 598 | const projectDefaultMergeMethod = | ||
| 599 | resolveProjectSettings( | ||
| 600 | environmentConfigs.get(environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS, | ||
| 601 | reference.projectId, | ||
| 602 | ).settings.pullRequestMergeMethod ?? undefined; | ||
| 592 | const [mergeMethodSelection, setMergeMethodSelection] = useState<{ | 603 | const [mergeMethodSelection, setMergeMethodSelection] = useState<{ |
| 593 | readonly pullRequestKey: string; | 604 | readonly pullRequestKey: string; |
| 594 | readonly method: PullRequestMergeMethod; | 605 | readonly method: PullRequestMergeMethod; |
| ... | @@ -834,9 +845,10 @@ export function PullRequestDetailPanel({ | ... | @@ -834,9 +845,10 @@ export function PullRequestDetailPanel({ |
| 834 | )?.repositoryIdentity; | 845 | )?.repositoryIdentity; |
| 835 | return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); | 846 | return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); |
| 836 | }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); | 847 | }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); |
| 837 | // Project settings store the override under the sidebar group's key, which a duplicate row | 848 | // Project settings stored the override under the sidebar group's key, which a duplicate row |
| 838 | // borrows from its siblings, so the project alone does not always name the same key. | 849 | // borrows from its siblings, so the project alone does not always name the same key. |
| 839 | const projectDefaultMergeMethod = useMemo(() => { | 850 | const legacyProjectDefaultMergeMethod = useMemo(() => { |
| 851 | if (projectDefaultMergeMethod !== undefined) return undefined; | ||
| 840 | const project = projects.find( | 852 | const project = projects.find( |
| 841 | (candidate) => | 853 | (candidate) => |
| 842 | candidate.environmentId === environmentId && candidate.id === reference.projectId, | 854 | candidate.environmentId === environmentId && candidate.id === reference.projectId, |
| ... | @@ -849,11 +861,12 @@ export function PullRequestDetailPanel({ | ... | @@ -849,11 +861,12 @@ export function PullRequestDetailPanel({ |
| 849 | primaryEnvironmentId, | 861 | primaryEnvironmentId, |
| 850 | }).get(derivePhysicalProjectKey(project)) ?? | 862 | }).get(derivePhysicalProjectKey(project)) ?? |
| 851 | deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); | 863 | deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); |
| 852 | return mergeMethodOverrides[projectKey]; | 864 | return legacyMergeMethodOverrides[projectKey]; |
| 853 | }, [ | 865 | }, [ |
| 854 | environmentId, | 866 | environmentId, |
| 855 | mergeMethodOverrides, | 867 | legacyMergeMethodOverrides, |
| 856 | primaryEnvironmentId, | 868 | primaryEnvironmentId, |
| 869 | projectDefaultMergeMethod, | ||
| 857 | projectGroupingSettings, | 870 | projectGroupingSettings, |
| 858 | projects, | 871 | projects, |
| 859 | reference.projectId, | 872 | reference.projectId, |
| ... | @@ -1327,7 +1340,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1327,7 +1340,7 @@ export function PullRequestDetailPanel({ |
| 1327 | const selectedMergeMethod = resolvePullRequestMergeMethod( | 1340 | const selectedMergeMethod = resolvePullRequestMergeMethod( |
| 1328 | allowedMergeMethods, | 1341 | allowedMergeMethods, |
| 1329 | currentMergeMethod, | 1342 | currentMergeMethod, |
| 1330 | projectDefaultMergeMethod, | 1343 | projectDefaultMergeMethod ?? legacyProjectDefaultMergeMethod, |
| 1331 | lastSelectedMergeMethod, | 1344 | lastSelectedMergeMethod, |
| 1332 | ); | 1345 | ); |
| 1333 | const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; | 1346 | const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; |
apps/web/src/components/settings/ConnectionsSettings.tsx+4-1| ... | @@ -3133,7 +3133,10 @@ export function ConnectionsSettings() { | ... | @@ -3133,7 +3133,10 @@ export function ConnectionsSettings() { |
| 3133 | <SettingsPageContainer> | 3133 | <SettingsPageContainer> |
| 3134 | {canManageLocalBackend ? ( | 3134 | {canManageLocalBackend ? ( |
| 3135 | <> | 3135 | <> |
| 3136 | <SettingsSection {...searchableSetting("connections-environment")}> | 3136 | <SettingsSection |
| 3137 | {...searchableSetting("connections-environment")} | ||
| 3138 | title={primaryEnvironment?.label ?? "Primary environment"} | ||
| 3139 | > | ||
| 3137 | {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( | 3140 | {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( |
| 3138 | <SettingsRow | 3141 | <SettingsRow |
| 3139 | title={ | 3142 | title={ |
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-20| ... | @@ -7,12 +7,11 @@ import { | ... | @@ -7,12 +7,11 @@ import { |
| 7 | FolderOpenIcon, | 7 | FolderOpenIcon, |
| 8 | InfoIcon, | 8 | InfoIcon, |
| 9 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | ||
| 11 | import { | 10 | import { |
| 12 | isAtomCommandInterrupted, | 11 | isAtomCommandInterrupted, |
| 13 | squashAtomCommandFailure, | 12 | squashAtomCommandFailure, |
| 14 | } from "@t3tools/client-runtime/state/runtime"; | 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 15 | import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; | 14 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 16 | import type { | 15 | import type { |
| 17 | ServerProcessDiagnosticsEntry, | 16 | ServerProcessDiagnosticsEntry, |
| 18 | ServerProcessResourceHistorySummary, | 17 | ServerProcessResourceHistorySummary, |
| ... | @@ -26,13 +25,8 @@ import { ensureLocalApi } from "../../localApi"; | ... | @@ -26,13 +25,8 @@ import { ensureLocalApi } from "../../localApi"; |
| 26 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 27 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | 26 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 28 | import { useEnvironmentQuery } from "../../state/query"; | 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 29 | import { | 28 | import { serverEnvironment } from "../../state/server"; |
| 30 | primaryServerAvailableEditorsAtom, | ||
| 31 | primaryServerObservabilityAtom, | ||
| 32 | serverEnvironment, | ||
| 33 | } from "../../state/server"; | ||
| 34 | import { shellEnvironment } from "../../state/shell"; | 29 | import { shellEnvironment } from "../../state/shell"; |
| 35 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 36 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 30 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 37 | import { Button } from "../ui/button"; | 31 | import { Button } from "../ui/button"; |
| 38 | import { ScrollArea } from "../ui/scroll-area"; | 32 | import { ScrollArea } from "../ui/scroll-area"; |
| ... | @@ -43,6 +37,7 @@ import { ExpandableText } from "./ExpandableText"; | ... | @@ -43,6 +37,7 @@ import { ExpandableText } from "./ExpandableText"; |
| 43 | import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; | 37 | import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; |
| 44 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | 38 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 45 | import { useAtomCommand } from "../../state/use-atom-command"; | 39 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 40 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 46 | 41 | ||
| 47 | const NUMBER_FORMAT = new Intl.NumberFormat(); | 42 | const NUMBER_FORMAT = new Intl.NumberFormat(); |
| 48 | 43 | ||
| ... | @@ -776,10 +771,12 @@ function DiagnosticsRefreshButton({ | ... | @@ -776,10 +771,12 @@ function DiagnosticsRefreshButton({ |
| 776 | } | 771 | } |
| 777 | 772 | ||
| 778 | export function DiagnosticsSettingsPanel() { | 773 | export function DiagnosticsSettingsPanel() { |
| 779 | const observability = useAtomValue(primaryServerObservabilityAtom); | 774 | const { environment } = useSettingsScope(); |
| 780 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); | 775 | // The boundary only mounts this page when the selection resolves to one |
| 781 | const primaryEnvironment = usePrimaryEnvironment(); | 776 | // connected environment, so the representative is the one to inspect. |
| 782 | const environmentId = primaryEnvironment?.environmentId ?? null; | 777 | const environmentId = environment?.environmentId ?? null; |
| 778 | const observability = environment?.serverConfig?.observability; | ||
| 779 | const availableEditors = environment?.serverConfig?.availableEditors; | ||
| 783 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { | 780 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { |
| 784 | reportFailure: false, | 781 | reportFailure: false, |
| 785 | }); | 782 | }); |
| ... | @@ -827,8 +824,15 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -827,8 +824,15 @@ export function DiagnosticsSettingsPanel() { |
| 827 | const signalingPidRef = useRef<number | null>(null); | 824 | const signalingPidRef = useRef<number | null>(null); |
| 828 | const environmentIdRef = useRef(environmentId); | 825 | const environmentIdRef = useRef(environmentId); |
| 829 | const processDataRef = useRef(processData); | 826 | const processDataRef = useRef(processData); |
| 830 | environmentIdRef.current = environmentId; | 827 | useEffect(() => { |
| 831 | processDataRef.current = processData; | 828 | processDataRef.current = processData; |
| 829 | }, [processData]); | ||
| 830 | useEffect(() => { | ||
| 831 | environmentIdRef.current = environmentId; | ||
| 832 | return () => { | ||
| 833 | environmentIdRef.current = null; | ||
| 834 | }; | ||
| 835 | }, [environmentId]); | ||
| 832 | 836 | ||
| 833 | const openLogsDirectory = useCallback(() => { | 837 | const openLogsDirectory = useCallback(() => { |
| 834 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; | 838 | const logsDirectoryPath = observability?.logsDirectoryPath ?? null; |
| ... | @@ -868,6 +872,9 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -868,6 +872,9 @@ export function DiagnosticsSettingsPanel() { |
| 868 | const isProcessInitialLoading = isProcessPending && processData === null; | 872 | const isProcessInitialLoading = isProcessPending && processData === null; |
| 869 | const signalProcess = useCallback( | 873 | const signalProcess = useCallback( |
| 870 | async (pid: number, signal: ServerProcessSignal) => { | 874 | async (pid: number, signal: ServerProcessSignal) => { |
| 875 | const targetEnvironmentId = environmentIdRef.current; | ||
| 876 | const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); | ||
| 877 | if (targetEnvironmentId === null || process === undefined) return; | ||
| 871 | if (signalingPidRef.current !== null) return; | 878 | if (signalingPidRef.current !== null) return; |
| 872 | signalingPidRef.current = pid; | 879 | signalingPidRef.current = pid; |
| 873 | setSignalingPid(pid); | 880 | setSignalingPid(pid); |
| ... | @@ -896,20 +903,21 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -896,20 +903,21 @@ export function DiagnosticsSettingsPanel() { |
| 896 | return; | 903 | return; |
| 897 | } | 904 | } |
| 898 | } | 905 | } |
| 899 | const currentEnvironmentId = environmentIdRef.current; | 906 | if (environmentIdRef.current !== targetEnvironmentId) { |
| 900 | if (currentEnvironmentId === null) { | ||
| 901 | clearSignaling(); | 907 | clearSignaling(); |
| 902 | return; | 908 | return; |
| 903 | } | 909 | } |
| 904 | const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); | 910 | if ( |
| 905 | if (process === undefined) { | 911 | processDataRef.current?.processes.find((entry) => entry.pid === pid)?.startTimeMs !== |
| 912 | process.startTimeMs | ||
| 913 | ) { | ||
| 906 | clearSignaling(); | 914 | clearSignaling(); |
| 907 | return; | 915 | return; |
| 908 | } | 916 | } |
| 909 | 917 | ||
| 910 | try { | 918 | try { |
| 911 | const result = await signalServerProcess({ | 919 | const result = await signalServerProcess({ |
| 912 | environmentId: currentEnvironmentId, | 920 | environmentId: targetEnvironmentId, |
| 913 | input: { pid, startTimeMs: process.startTimeMs, signal }, | 921 | input: { pid, startTimeMs: process.startTimeMs, signal }, |
| 914 | }); | 922 | }); |
| 915 | if (result._tag === "Failure") { | 923 | if (result._tag === "Failure") { |
| ... | @@ -960,7 +968,7 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -960,7 +968,7 @@ export function DiagnosticsSettingsPanel() { |
| 960 | 968 | ||
| 961 | return ( | 969 | return ( |
| 962 | <SettingsPageContainer width="expanded" className="gap-10"> | 970 | <SettingsPageContainer width="expanded" className="gap-10"> |
| 963 | <ResourceTelemetryDiagnostics /> | 971 | <ResourceTelemetryDiagnostics environmentId={environmentId} /> |
| 964 | 972 | ||
| 965 | <SettingsSection | 973 | <SettingsSection |
| 966 | title="Live Processes" | 974 | title="Live Processes" |
apps/web/src/components/settings/IntegrationsSettings.test.tsx+12| ... | @@ -38,6 +38,18 @@ vi.mock("./settingsLayout", async (importOriginal) => ({ | ... | @@ -38,6 +38,18 @@ vi.mock("./settingsLayout", async (importOriginal) => ({ |
| 38 | ...(await importOriginal<typeof import("./settingsLayout")>()), | 38 | ...(await importOriginal<typeof import("./settingsLayout")>()), |
| 39 | SettingsPageContainer: ({ children }: { children: ReactNode }) => children, | 39 | SettingsPageContainer: ({ children }: { children: ReactNode }) => children, |
| 40 | })); | 40 | })); |
| 41 | // The scoped agent-access rows need the settings layout's scope provider; | ||
| 42 | // this test covers the device-local browser sections only. | ||
| 43 | vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null })); | ||
| 44 | vi.mock("./SettingsScopeContext", () => ({ | ||
| 45 | useSettingsScope: () => ({ | ||
| 46 | scope: { kind: "all", environmentIds: [] }, | ||
| 47 | environment: null, | ||
| 48 | connectedEnvironments: [], | ||
| 49 | targets: [], | ||
| 50 | }), | ||
| 51 | useOptionalSettingsScope: () => null, | ||
| 52 | })); | ||
| 41 | 53 | ||
| 42 | import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; | 54 | import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; |
| 43 | import { platformSetupStatus } from "../device/DeviceSetup"; | 55 | import { platformSetupStatus } from "../device/DeviceSetup"; |
apps/web/src/components/settings/IntegrationsSettings.tsx+16-59| ... | @@ -36,7 +36,6 @@ import { | ... | @@ -36,7 +36,6 @@ import { |
| 36 | type PreviewViewportSetting, | 36 | type PreviewViewportSetting, |
| 37 | } from "@t3tools/contracts"; | 37 | } from "@t3tools/contracts"; |
| 38 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; | 38 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; |
| 39 | import { Link } from "@tanstack/react-router"; | ||
| 40 | import { MoreVertical, Plus as PlusIcon } from "lucide-react"; | 39 | import { MoreVertical, Plus as PlusIcon } from "lucide-react"; |
| 41 | import { useCallback, useRef, useState } from "react"; | 40 | import { useCallback, useRef, useState } from "react"; |
| 42 | 41 | ||
| ... | @@ -110,6 +109,8 @@ import { | ... | @@ -110,6 +109,8 @@ import { |
| 110 | SettingsSection, | 109 | SettingsSection, |
| 111 | } from "./settingsLayout"; | 110 | } from "./settingsLayout"; |
| 112 | import { searchableSetting } from "./settingsSearch"; | 111 | import { searchableSetting } from "./settingsSearch"; |
| 112 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | ||
| 113 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 113 | import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; | 114 | import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; |
| 114 | import type { ImportOutcome } from "./browserImportWizard.logic"; | 115 | import type { ImportOutcome } from "./browserImportWizard.logic"; |
| 115 | 116 | ||
| ... | @@ -564,65 +565,22 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) | ... | @@ -564,65 +565,22 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) |
| 564 | ); | 565 | ); |
| 565 | } | 566 | } |
| 566 | 567 | ||
| 567 | function AgentBrowserAccessSetting() { | 568 | /** |
| 568 | return ( | 569 | * Device support installs helper processes and hosts on one machine, so it |
| 569 | <SettingsRow | 570 | * follows the environment crumb. With several environments selected it shows |
| 570 | {...searchableSetting("agent-browser-access")} | 571 | * the representative, named in the section title. |
| 571 | description="Choose whether agents can use the preview browser for all projects or a specific project." | 572 | */ |
| 572 | control={ | ||
| 573 | <Button | ||
| 574 | render={ | ||
| 575 | <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} /> | ||
| 576 | } | ||
| 577 | size="sm" | ||
| 578 | variant="outline" | ||
| 579 | > | ||
| 580 | Project settings | ||
| 581 | </Button> | ||
| 582 | } | ||
| 583 | /> | ||
| 584 | ); | ||
| 585 | } | ||
| 586 | |||
| 587 | function DeviceIntegrationSettings() { | 573 | function DeviceIntegrationSettings() { |
| 588 | const primaryEnvironment = usePrimaryEnvironment(); | 574 | const { scope, environment: selected, connectedEnvironments } = useSettingsScope(); |
| 589 | const { environments } = useEnvironments(); | ||
| 590 | const [selectedId, setSelectedId] = useState<string | null>(null); | ||
| 591 | const selected = | ||
| 592 | environments.find((environment) => environment.environmentId === selectedId) ?? | ||
| 593 | environments.find( | ||
| 594 | (environment) => environment.environmentId === primaryEnvironment?.environmentId, | ||
| 595 | ) ?? | ||
| 596 | environments[0]; | ||
| 597 | const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; | 575 | const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; |
| 598 | const environmentId = connected ? selected.environmentId : null; | 576 | const environmentId = connected ? selected.environmentId : null; |
| 577 | const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1; | ||
| 599 | 578 | ||
| 600 | return ( | 579 | return ( |
| 601 | <SettingsSection id="devices" title="Devices"> | 580 | <SettingsSection |
| 602 | {environments.length > 1 ? ( | 581 | id="devices" |
| 603 | <SettingsRow | 582 | title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"} |
| 604 | title="Environment" | 583 | > |
| 605 | description="Device support and hosts are shared by all projects in this environment." | ||
| 606 | control={ | ||
| 607 | <Select | ||
| 608 | value={selected?.environmentId ?? ""} | ||
| 609 | onValueChange={(value) => setSelectedId(value)} | ||
| 610 | > | ||
| 611 | <SelectTrigger size="sm" aria-label="Device environment"> | ||
| 612 | <SelectValue>{selected?.label ?? "Select environment"}</SelectValue> | ||
| 613 | </SelectTrigger> | ||
| 614 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 615 | {environments.map((environment) => ( | ||
| 616 | <SelectItem key={environment.environmentId} value={environment.environmentId}> | ||
| 617 | {environment.label} | ||
| 618 | {environment.connection.phase === "connected" ? "" : " · Offline"} | ||
| 619 | </SelectItem> | ||
| 620 | ))} | ||
| 621 | </SelectPopup> | ||
| 622 | </Select> | ||
| 623 | } | ||
| 624 | /> | ||
| 625 | ) : null} | ||
| 626 | <DeviceIntegrationControls | 584 | <DeviceIntegrationControls |
| 627 | key={selected?.environmentId ?? "none"} | 585 | key={selected?.environmentId ?? "none"} |
| 628 | environmentId={environmentId} | 586 | environmentId={environmentId} |
| ... | @@ -1339,11 +1297,10 @@ export function IntegrationsSettingsPanel() { | ... | @@ -1339,11 +1297,10 @@ export function IntegrationsSettingsPanel() { |
| 1339 | 1297 | ||
| 1340 | return ( | 1298 | return ( |
| 1341 | <SettingsPageContainer> | 1299 | <SettingsPageContainer> |
| 1300 | {/* Server-authoritative agent access is scoped by the header selection; | ||
| 1301 | the preview defaults below are device-local and ignore it. */} | ||
| 1302 | <ProjectDefaultsSettings category="integrations" /> | ||
| 1342 | <SettingsSection id="browser" title="Browser"> | 1303 | <SettingsSection id="browser" title="Browser"> |
| 1343 | {/* Server-authoritative, so it stays editable on any client anchored to | ||
| 1344 | a server; `serverScoped` covers the hosted app, which has none. It | ||
| 1345 | sits outside the block covering the desktop-only defaults. */} | ||
| 1346 | <AgentBrowserAccessSetting /> | ||
| 1347 | {previewDefaultsDisabled ? ( | 1304 | {previewDefaultsDisabled ? ( |
| 1348 | <SettingsUnavailableGroup message="Only available in the desktop app."> | 1305 | <SettingsUnavailableGroup message="Only available in the desktop app."> |
| 1349 | {previewDefaults} | 1306 | {previewDefaults} |
apps/web/src/components/settings/KeybindingsSettings.tsx+31-26| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | type ServerRemoveKeybindingInput, | 26 | type ServerRemoveKeybindingInput, |
| 27 | type ServerUpsertKeybindingInput, | 27 | type ServerUpsertKeybindingInput, |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import { useAtomValue } from "@effect/atom-react"; | 29 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; |
| 30 | import { | 30 | import { |
| 31 | isAtomCommandInterrupted, | 31 | isAtomCommandInterrupted, |
| 32 | squashAtomCommandFailure, | 32 | squashAtomCommandFailure, |
| ... | @@ -36,13 +36,8 @@ import { isElectron } from "../../env"; | ... | @@ -36,13 +36,8 @@ import { isElectron } from "../../env"; |
| 36 | import { useOpenInPreferredEditor } from "../../editorPreferences"; | 36 | import { useOpenInPreferredEditor } from "../../editorPreferences"; |
| 37 | import { formatShortcutLabel } from "../../keybindings"; | 37 | import { formatShortcutLabel } from "../../keybindings"; |
| 38 | import { cn } from "../../lib/utils"; | 38 | import { cn } from "../../lib/utils"; |
| 39 | import { | 39 | import { serverEnvironment } from "../../state/server"; |
| 40 | primaryServerAvailableEditorsAtom, | 40 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 41 | primaryServerKeybindingsAtom, | ||
| 42 | primaryServerKeybindingsConfigPathAtom, | ||
| 43 | serverEnvironment, | ||
| 44 | } from "../../state/server"; | ||
| 45 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 46 | import { Badge } from "../ui/badge"; | 41 | import { Badge } from "../ui/badge"; |
| 47 | import { Button } from "../ui/button"; | 42 | import { Button } from "../ui/button"; |
| 48 | import { Input } from "../ui/input"; | 43 | import { Input } from "../ui/input"; |
| ... | @@ -1335,10 +1330,13 @@ function BrowserKeybindingNotice() { | ... | @@ -1335,10 +1330,13 @@ function BrowserKeybindingNotice() { |
| 1335 | } | 1330 | } |
| 1336 | 1331 | ||
| 1337 | export function KeybindingsSettingsPanel() { | 1332 | export function KeybindingsSettingsPanel() { |
| 1338 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 1333 | // The representative environment supplies the displayed bindings; edits |
| 1339 | const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); | 1334 | // fan out to every connected environment in the selection, so one |
| 1340 | const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); | 1335 | // shortcut change reaches each machine the user runs T3 Code on. |
| 1341 | const primaryEnvironment = usePrimaryEnvironment(); | 1336 | const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); |
| 1337 | const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 1338 | const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; | ||
| 1339 | const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; | ||
| 1342 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | 1340 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { |
| 1343 | reportFailure: false, | 1341 | reportFailure: false, |
| 1344 | }); | 1342 | }); |
| ... | @@ -1411,17 +1409,19 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1411,17 +1409,19 @@ export function KeybindingsSettingsPanel() { |
| 1411 | ...(input.replace ? { replace: input.replace } : {}), | 1409 | ...(input.replace ? { replace: input.replace } : {}), |
| 1412 | }; | 1410 | }; |
| 1413 | void (async () => { | 1411 | void (async () => { |
| 1414 | const result = await upsertKeybinding({ | 1412 | const results = await Promise.all( |
| 1415 | environmentId: primaryEnvironment.environmentId, | 1413 | connectedEnvironments.map((target) => |
| 1416 | input: payload, | 1414 | upsertKeybinding({ environmentId: target.environmentId, input: payload }), |
| 1417 | }); | 1415 | ), |
| 1416 | ); | ||
| 1418 | setSavingCommand(null); | 1417 | setSavingCommand(null); |
| 1419 | if (result._tag === "Success") { | 1418 | const failed = results.find((result) => result._tag === "Failure"); |
| 1419 | if (!failed) { | ||
| 1420 | setIsAddingBinding(false); | 1420 | setIsAddingBinding(false); |
| 1421 | return; | 1421 | return; |
| 1422 | } | 1422 | } |
| 1423 | if (!isAtomCommandInterrupted(result)) { | 1423 | if (!isAtomCommandInterrupted(failed)) { |
| 1424 | const error = squashAtomCommandFailure(result); | 1424 | const error = squashAtomCommandFailure(failed); |
| 1425 | toastManager.add({ | 1425 | toastManager.add({ |
| 1426 | title: "Unable to save keybinding", | 1426 | title: "Unable to save keybinding", |
| 1427 | description: error instanceof Error ? error.message : "The keybinding was not saved.", | 1427 | description: error instanceof Error ? error.message : "The keybinding was not saved.", |
| ... | @@ -1430,7 +1430,7 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1430,7 +1430,7 @@ export function KeybindingsSettingsPanel() { |
| 1430 | } | 1430 | } |
| 1431 | })(); | 1431 | })(); |
| 1432 | }, | 1432 | }, |
| 1433 | [primaryEnvironment, upsertKeybinding], | 1433 | [connectedEnvironments, primaryEnvironment, upsertKeybinding], |
| 1434 | ); | 1434 | ); |
| 1435 | 1435 | ||
| 1436 | const removeKeybinding = useCallback( | 1436 | const removeKeybinding = useCallback( |
| ... | @@ -1438,12 +1438,17 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1438,12 +1438,17 @@ export function KeybindingsSettingsPanel() { |
| 1438 | if (!primaryEnvironment) return; | 1438 | if (!primaryEnvironment) return; |
| 1439 | setSavingCommand(row.command); | 1439 | setSavingCommand(row.command); |
| 1440 | void (async () => { | 1440 | void (async () => { |
| 1441 | const result = await removeKeybindingMutation({ | 1441 | const results = await Promise.all( |
| 1442 | environmentId: primaryEnvironment.environmentId, | 1442 | connectedEnvironments.map((target) => |
| 1443 | input: rowKeybindingTarget(row), | 1443 | removeKeybindingMutation({ |
| 1444 | }); | 1444 | environmentId: target.environmentId, |
| 1445 | input: rowKeybindingTarget(row), | ||
| 1446 | }), | ||
| 1447 | ), | ||
| 1448 | ); | ||
| 1445 | setSavingCommand(null); | 1449 | setSavingCommand(null); |
| 1446 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 1450 | const result = results.find((entry) => entry._tag === "Failure") ?? results[0]; |
| 1451 | if (result?._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 1447 | const error = squashAtomCommandFailure(result); | 1452 | const error = squashAtomCommandFailure(result); |
| 1448 | toastManager.add({ | 1453 | toastManager.add({ |
| 1449 | title: "Unable to remove keybinding", | 1454 | title: "Unable to remove keybinding", |
| ... | @@ -1453,7 +1458,7 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1453,7 +1458,7 @@ export function KeybindingsSettingsPanel() { |
| 1453 | } | 1458 | } |
| 1454 | })(); | 1459 | })(); |
| 1455 | }, | 1460 | }, |
| 1456 | [primaryEnvironment, removeKeybindingMutation], | 1461 | [connectedEnvironments, primaryEnvironment, removeKeybindingMutation], |
| 1457 | ); | 1462 | ); |
| 1458 | 1463 | ||
| 1459 | const resetKeybinding = useCallback( | 1464 | const resetKeybinding = useCallback( |
apps/web/src/components/settings/ProjectActionsSettings.tsx created+226| ... | @@ -0,0 +1,226 @@ | ||
| 1 | import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 7 | import { ChevronDownIcon, PlusIcon } from "lucide-react"; | ||
| 8 | import { useCallback, useMemo, useState } from "react"; | ||
| 9 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; | ||
| 10 | import { useEnvironments } from "../../state/environments"; | ||
| 11 | import { | ||
| 12 | EMPTY_PROJECT_SCRIPT_INPUT, | ||
| 13 | editorRequestForScript, | ||
| 14 | ProjectScriptEditorDialog, | ||
| 15 | ScriptIcon, | ||
| 16 | type NewProjectScriptInput, | ||
| 17 | type ProjectScriptEditorRequest, | ||
| 18 | } from "../projectScriptEditor"; | ||
| 19 | import { Button } from "../ui/button"; | ||
| 20 | import { | ||
| 21 | Menu, | ||
| 22 | MenuGroup, | ||
| 23 | MenuGroupLabel, | ||
| 24 | MenuItem, | ||
| 25 | MenuPopup, | ||
| 26 | MenuSeparator, | ||
| 27 | MenuTrigger, | ||
| 28 | } from "../ui/menu"; | ||
| 29 | import { ProjectActionsList } from "./ProjectActionsList"; | ||
| 30 | import { useProjectScriptSettings } from "./useProjectScriptSettings"; | ||
| 31 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 32 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * A project's actions on each selected environment. Actions belong to a | ||
| 36 | * project, so this only renders at a project scope; the environment's | ||
| 37 | * `defaultProjectScripts` is the layer a project without its own list | ||
| 38 | * inherits. Shortcuts are environment-wide, so the same action id shares its | ||
| 39 | * binding on an environment. | ||
| 40 | */ | ||
| 41 | export function ProjectActionsSettings() { | ||
| 42 | const { scope, targets, target } = useSettingsScope(); | ||
| 43 | const { environments } = useEnvironments(); | ||
| 44 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; | ||
| 45 | const representativeConfig = target | ||
| 46 | ? environments.find((environment) => environment.environmentId === target.environmentId) | ||
| 47 | ?.serverConfig | ||
| 48 | : undefined; | ||
| 49 | const scripts = target?.settings.defaultProjectScripts ?? []; | ||
| 50 | const keybindings = representativeConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 51 | const mixed = targets.some( | ||
| 52 | (candidate) => | ||
| 53 | JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts), | ||
| 54 | ); | ||
| 55 | const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null); | ||
| 56 | const memberById = new Map( | ||
| 57 | isProjectScope ? scope.members.map((member) => [member.id, member]) : [], | ||
| 58 | ); | ||
| 59 | const { saving, persist, submit } = useProjectScriptSettings( | ||
| 60 | targets.flatMap((candidate) => { | ||
| 61 | const environment = environments.find( | ||
| 62 | (entry) => entry.environmentId === candidate.environmentId, | ||
| 63 | ); | ||
| 64 | if (!environment?.serverConfig) return []; | ||
| 65 | const member = candidate.projectId ? memberById.get(candidate.projectId) : undefined; | ||
| 66 | // An older server ignores the override record, so a project edit there | ||
| 67 | // would report success and vanish; such environments are left out and | ||
| 68 | // the legacy per-project map keeps serving them. | ||
| 69 | if ( | ||
| 70 | member && | ||
| 71 | environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true | ||
| 72 | ) { | ||
| 73 | return []; | ||
| 74 | } | ||
| 75 | return [ | ||
| 76 | { | ||
| 77 | environmentId: candidate.environmentId, | ||
| 78 | // Writes read the raw environment settings so an override entry is | ||
| 79 | // extended, not derived from already-resolved values. | ||
| 80 | settings: environment.serverConfig.settings, | ||
| 81 | keybindings: environment.serverConfig.keybindings, | ||
| 82 | ...(member ? { project: member } : {}), | ||
| 83 | }, | ||
| 84 | ]; | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | |||
| 88 | // A project's t3.json can declare actions to import. Read it from the | ||
| 89 | // representative checkout; the imported action still fans out. | ||
| 90 | const representativeMember = target?.projectId ? memberById.get(target.projectId) : undefined; | ||
| 91 | const t3File = useT3ProjectFileState( | ||
| 92 | representativeMember?.environmentId ?? EnvironmentId.make("none"), | ||
| 93 | representativeMember?.workspaceRoot ?? null, | ||
| 94 | ); | ||
| 95 | const importableScripts = useMemo( | ||
| 96 | () => | ||
| 97 | t3File.scripts.filter( | ||
| 98 | (fileScript) => | ||
| 99 | !scripts.some( | ||
| 100 | (script) => | ||
| 101 | script.command === fileScript.command || | ||
| 102 | script.name.toLowerCase() === fileScript.name.toLowerCase(), | ||
| 103 | ), | ||
| 104 | ), | ||
| 105 | [scripts, t3File.scripts], | ||
| 106 | ); | ||
| 107 | const importFileScript = useCallback( | ||
| 108 | async (fileScript: T3ProjectFileScript) => { | ||
| 109 | const payload: NewProjectScriptInput = { | ||
| 110 | name: fileScript.name, | ||
| 111 | command: fileScript.command, | ||
| 112 | icon: fileScript.icon ?? "play", | ||
| 113 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, | ||
| 114 | keybinding: null, | ||
| 115 | previewUrl: fileScript.previewUrl ?? null, | ||
| 116 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, | ||
| 117 | }; | ||
| 118 | const result = await submit(null, payload); | ||
| 119 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 120 | const error = squashAtomCommandFailure(result); | ||
| 121 | setRequest({ | ||
| 122 | scriptId: null, | ||
| 123 | initial: payload, | ||
| 124 | error: error instanceof Error ? error.message : "Failed to import action.", | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | }, | ||
| 128 | [submit], | ||
| 129 | ); | ||
| 130 | |||
| 131 | return ( | ||
| 132 | <SettingsSection id="project-actions" title="Actions"> | ||
| 133 | <SettingsRow | ||
| 134 | serverScoped | ||
| 135 | settingKeys={["defaultProjectScripts"]} | ||
| 136 | mixed={mixed} | ||
| 137 | title="Actions" | ||
| 138 | description="Commands that run in this project's checkout or its worktree, with optional shortcuts." | ||
| 139 | onResetOverride={() => void persist(() => null)} | ||
| 140 | control={ | ||
| 141 | <div className="flex flex-wrap items-center gap-1.5"> | ||
| 142 | {importableScripts.length > 0 ? ( | ||
| 143 | <Menu> | ||
| 144 | <MenuTrigger | ||
| 145 | render={ | ||
| 146 | <Button | ||
| 147 | id="import-scripts" | ||
| 148 | size="xs" | ||
| 149 | variant="ghost" | ||
| 150 | disabled={saving} | ||
| 151 | type="button" | ||
| 152 | /> | ||
| 153 | } | ||
| 154 | > | ||
| 155 | Import scripts | ||
| 156 | <ChevronDownIcon className="size-3.5" /> | ||
| 157 | </MenuTrigger> | ||
| 158 | <MenuPopup align="end" className="w-72"> | ||
| 159 | <MenuGroup> | ||
| 160 | <MenuGroupLabel>Import from t3.json</MenuGroupLabel> | ||
| 161 | <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground"> | ||
| 162 | Add actions declared by this checkout without editing them first. | ||
| 163 | </p> | ||
| 164 | </MenuGroup> | ||
| 165 | <MenuSeparator /> | ||
| 166 | {importableScripts.map((fileScript) => ( | ||
| 167 | <MenuItem | ||
| 168 | key={`${fileScript.name} ${fileScript.command}`} | ||
| 169 | onClick={() => void importFileScript(fileScript)} | ||
| 170 | > | ||
| 171 | <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" /> | ||
| 172 | <div className="min-w-0 flex-1"> | ||
| 173 | <div className="truncate font-medium">{fileScript.name}</div> | ||
| 174 | <div className="truncate font-mono text-muted-foreground"> | ||
| 175 | {fileScript.command} | ||
| 176 | </div> | ||
| 177 | </div> | ||
| 178 | </MenuItem> | ||
| 179 | ))} | ||
| 180 | </MenuPopup> | ||
| 181 | </Menu> | ||
| 182 | ) : null} | ||
| 183 | <Button | ||
| 184 | size="xs" | ||
| 185 | variant="outline" | ||
| 186 | disabled={saving || targets.length === 0} | ||
| 187 | onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })} | ||
| 188 | > | ||
| 189 | <PlusIcon className="size-3.5" /> | ||
| 190 | Add action | ||
| 191 | </Button> | ||
| 192 | </div> | ||
| 193 | } | ||
| 194 | /> | ||
| 195 | {mixed ? ( | ||
| 196 | <SettingsRow | ||
| 197 | title="Different actions across environments" | ||
| 198 | description="Choose one environment to edit its list. Adding an action here adds it on every selected environment." | ||
| 199 | /> | ||
| 200 | ) : ( | ||
| 201 | <ProjectActionsList | ||
| 202 | scripts={scripts} | ||
| 203 | keybindings={keybindings} | ||
| 204 | disabled={saving} | ||
| 205 | onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))} | ||
| 206 | /> | ||
| 207 | )} | ||
| 208 | {t3File.status === "invalid" ? ( | ||
| 209 | <SettingsRow | ||
| 210 | title="t3.json is invalid" | ||
| 211 | description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values." | ||
| 212 | className="text-warning" | ||
| 213 | /> | ||
| 214 | ) : null} | ||
| 215 | <ProjectScriptEditorDialog | ||
| 216 | request={request} | ||
| 217 | scripts={scripts} | ||
| 218 | onSubmit={submit} | ||
| 219 | onDelete={(id) => | ||
| 220 | void persist((current) => current.filter((script) => script.id !== id), id, null) | ||
| 221 | } | ||
| 222 | onClose={() => setRequest(null)} | ||
| 223 | /> | ||
| 224 | </SettingsSection> | ||
| 225 | ); | ||
| 226 | } | ||
apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx deleted-114| ... | @@ -1,114 +0,0 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 3 | import { PlusIcon } from "lucide-react"; | ||
| 4 | import { useState } from "react"; | ||
| 5 | import { useEnvironments } from "../../state/environments"; | ||
| 6 | import { | ||
| 7 | EMPTY_PROJECT_SCRIPT_INPUT, | ||
| 8 | editorRequestForScript, | ||
| 9 | ProjectScriptEditorDialog, | ||
| 10 | type ProjectScriptEditorRequest, | ||
| 11 | } from "../projectScriptEditor"; | ||
| 12 | import { Button } from "../ui/button"; | ||
| 13 | import { ProjectActionsList } from "./ProjectActionsList"; | ||
| 14 | import { useProjectScriptSettings } from "./ProjectSettingsPanel"; | ||
| 15 | import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 16 | |||
| 17 | export function ProjectDefaultActionsSettings({ | ||
| 18 | environmentId, | ||
| 19 | }: { | ||
| 20 | environmentId: EnvironmentId | null; | ||
| 21 | }) { | ||
| 22 | const { environments } = useEnvironments(); | ||
| 23 | const targets = environments.filter( | ||
| 24 | (environment) => | ||
| 25 | (environmentId === null || environment.environmentId === environmentId) && | ||
| 26 | environment.connection.phase === "connected" && | ||
| 27 | environment.serverConfig !== null, | ||
| 28 | ); | ||
| 29 | const representative = targets[0]?.serverConfig; | ||
| 30 | const scripts = representative?.settings.defaultProjectScripts ?? []; | ||
| 31 | const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 32 | const mixed = targets.some( | ||
| 33 | (target) => | ||
| 34 | JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== | ||
| 35 | JSON.stringify(scripts), | ||
| 36 | ); | ||
| 37 | const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null); | ||
| 38 | const { saving, persist, submit } = useProjectScriptSettings( | ||
| 39 | targets.flatMap(({ environmentId, serverConfig }) => | ||
| 40 | serverConfig | ||
| 41 | ? [ | ||
| 42 | { | ||
| 43 | environmentId, | ||
| 44 | settings: serverConfig.settings, | ||
| 45 | keybindings: serverConfig.keybindings, | ||
| 46 | }, | ||
| 47 | ] | ||
| 48 | : [], | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | |||
| 52 | return ( | ||
| 53 | <SettingsSection title="Actions"> | ||
| 54 | <SettingsRow | ||
| 55 | title="Import scripts" | ||
| 56 | aria-disabled | ||
| 57 | description="Select a project to import actions from its checkout's t3.json." | ||
| 58 | control={ | ||
| 59 | <Button size="xs" variant="ghost" disabled> | ||
| 60 | Import scripts | ||
| 61 | </Button> | ||
| 62 | } | ||
| 63 | /> | ||
| 64 | <SettingsRow | ||
| 65 | title="Default actions" | ||
| 66 | description="Available in every inheriting checkout. Commands run in that checkout or its worktree." | ||
| 67 | resetAction={ | ||
| 68 | targets.some( | ||
| 69 | (target) => (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, | ||
| 70 | ) ? ( | ||
| 71 | <SettingResetButton | ||
| 72 | label="default actions" | ||
| 73 | disabled={saving} | ||
| 74 | onClick={() => void persist(() => [])} | ||
| 75 | /> | ||
| 76 | ) : null | ||
| 77 | } | ||
| 78 | control={ | ||
| 79 | <Button | ||
| 80 | size="xs" | ||
| 81 | variant="outline" | ||
| 82 | disabled={saving || targets.length === 0} | ||
| 83 | onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })} | ||
| 84 | > | ||
| 85 | <PlusIcon className="size-3.5" /> | ||
| 86 | Add action | ||
| 87 | </Button> | ||
| 88 | } | ||
| 89 | /> | ||
| 90 | {mixed ? ( | ||
| 91 | <SettingsRow | ||
| 92 | title="Different actions across machines" | ||
| 93 | description="Select a machine to edit its actions. Adding an action applies to all selected connected machines." | ||
| 94 | /> | ||
| 95 | ) : ( | ||
| 96 | <ProjectActionsList | ||
| 97 | scripts={scripts} | ||
| 98 | keybindings={keybindings} | ||
| 99 | disabled={saving} | ||
| 100 | onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))} | ||
| 101 | /> | ||
| 102 | )} | ||
| 103 | <ProjectScriptEditorDialog | ||
| 104 | request={request} | ||
| 105 | scripts={scripts} | ||
| 106 | onSubmit={submit} | ||
| 107 | onDelete={(id) => | ||
| 108 | void persist((current) => current.filter((script) => script.id !== id), id, null) | ||
| 109 | } | ||
| 110 | onClose={() => setRequest(null)} | ||
| 111 | /> | ||
| 112 | </SettingsSection> | ||
| 113 | ); | ||
| 114 | } | ||
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+315-394| ... | @@ -1,17 +1,13 @@ | ... | @@ -1,17 +1,13 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_CLIENT_SETTINGS, | ||
| 3 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 4 | type EnvironmentId, | 3 | EnvironmentId, |
| 5 | type ModelSelection, | 4 | type ModelSelection, |
| 6 | type ProviderInstanceId, | 5 | type ProviderInstanceId, |
| 7 | type ServerSettingsPatch, | ||
| 8 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 7 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { useNavigate } from "@tanstack/react-router"; | 8 | import { useNavigate } from "@tanstack/react-router"; |
| 11 | import { useRef, useState } from "react"; | ||
| 12 | import { Trash2Icon } from "lucide-react"; | ||
| 13 | 9 | ||
| 14 | import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; | 10 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; |
| 15 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | 11 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 16 | import { | 12 | import { |
| 17 | applyProviderInstanceSettings, | 13 | applyProviderInstanceSettings, |
| ... | @@ -19,59 +15,47 @@ import { | ... | @@ -19,59 +15,47 @@ import { |
| 19 | resolveDefaultProviderModelSelection, | 15 | resolveDefaultProviderModelSelection, |
| 20 | sortProviderInstanceEntries, | 16 | sortProviderInstanceEntries, |
| 21 | } from "../../providerInstances"; | 17 | } from "../../providerInstances"; |
| 22 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; | 18 | import { useEnvironments } from "../../state/environments"; |
| 23 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | 19 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 24 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 25 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; | 20 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; |
| 26 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | 21 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 22 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; | ||
| 27 | import { TraitsPicker } from "../chat/TraitsPicker"; | 23 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 28 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 24 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 29 | import { toastManager } from "../ui/toast"; | 25 | import { toastManager } from "../ui/toast"; |
| 30 | import { Switch } from "../ui/switch"; | 26 | import { Switch } from "../ui/switch"; |
| 31 | import { Button } from "../ui/button"; | 27 | import type { ProjectSettingsCategory } from "./ProjectSettingsPanel"; |
| 32 | import { Input } from "../ui/input"; | ||
| 33 | import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; | ||
| 34 | import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; | ||
| 35 | import { searchableSetting } from "./settingsSearch"; | 28 | import { searchableSetting } from "./settingsSearch"; |
| 29 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 36 | import { | 30 | import { |
| 37 | SETTINGS_PICKER_TRIGGER_CLASSNAME, | 31 | SETTINGS_PICKER_TRIGGER_CLASSNAME, |
| 38 | SettingResetButton, | 32 | SettingResetButton, |
| 39 | SettingsPageContainer, | ||
| 40 | SettingsRow, | 33 | SettingsRow, |
| 41 | SettingsSection, | 34 | SettingsSection, |
| 42 | } from "./settingsLayout"; | 35 | } from "./settingsLayout"; |
| 36 | import { | ||
| 37 | useScopedSettings, | ||
| 38 | useScopedSettingsMixed, | ||
| 39 | useScopedSettingSource, | ||
| 40 | useUpdateScopedSettings, | ||
| 41 | } from "./useScopedSettings"; | ||
| 43 | 42 | ||
| 44 | /** Defaults are written only to the machines selected on the projects settings page. */ | 43 | /** |
| 45 | export function ProjectDefaultsSettings({ | 44 | * Rows for the settings a project may override. The same rows edit |
| 46 | environmentId, | 45 | * environment defaults at an environment scope and project overrides at a |
| 47 | }: { | 46 | * project or checkout scope; the scoped hooks route the write. |
| 48 | environmentId: EnvironmentId | null; | 47 | */ |
| 49 | }) { | 48 | export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { |
| 50 | const { environments } = useEnvironments(); | 49 | const { scope, target, targets, connectedEnvironments } = useSettingsScope(); |
| 51 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 50 | const settings = useScopedSettings(); |
| 52 | const clientSettings = useClientSettings(); | 51 | const updateSettings = useUpdateScopedSettings(); |
| 53 | const updateClientSettings = useUpdateClientSettings(); | ||
| 54 | const navigate = useNavigate(); | 52 | const navigate = useNavigate(); |
| 55 | const updateSettings = useAtomCommand( | 53 | const { environments } = useEnvironments(); |
| 56 | serverEnvironment.updateSettings, | 54 | const representative = target |
| 57 | "project defaults update", | 55 | ? environments.find((environment) => environment.environmentId === target.environmentId) |
| 58 | ); | 56 | : undefined; |
| 59 | const savingRef = useRef(new Set<string>()); | ||
| 60 | const [saving, setSaving] = useState<ReadonlySet<string>>(new Set()); | ||
| 61 | const scoped = environments.filter( | ||
| 62 | (environment) => environmentId === null || environment.environmentId === environmentId, | ||
| 63 | ); | ||
| 64 | const targets = scoped.filter( | ||
| 65 | (environment) => | ||
| 66 | environment.connection.phase === "connected" && environment.serverConfig !== null, | ||
| 67 | ); | ||
| 68 | const representative = | ||
| 69 | targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; | ||
| 70 | const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; | ||
| 71 | const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; | 57 | const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; |
| 72 | const settings = { ...serverSettings, ...clientSettings }; | 58 | const selection = resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection); |
| 73 | const storedSelection = serverSettings.defaultModelSelection; | ||
| 74 | const selection = resolveDefaultProviderModelSelection(providers, storedSelection); | ||
| 75 | const entries = sortProviderInstanceEntries( | 59 | const entries = sortProviderInstanceEntries( |
| 76 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), | 60 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), |
| 77 | ); | 61 | ); |
| ... | @@ -82,36 +66,46 @@ export function ProjectDefaultsSettings({ | ... | @@ -82,36 +66,46 @@ export function ProjectDefaultsSettings({ |
| 82 | selection?.model, | 66 | selection?.model, |
| 83 | ); | 67 | ); |
| 84 | const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); | 68 | const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); |
| 85 | const mixedModel = targets.some( | 69 | const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); |
| 86 | (target) => | 70 | const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); |
| 87 | JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== | 71 | const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); |
| 88 | JSON.stringify(storedSelection), | 72 | const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); |
| 89 | ); | 73 | const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); |
| 90 | const mixedWorkspace = targets.some( | 74 | const modelSource = useScopedSettingSource(["defaultModelSelection"]); |
| 91 | (target) => | 75 | const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); |
| 92 | target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, | 76 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; |
| 93 | ); | 77 | const unavailable = connectedEnvironments.length === 0; |
| 94 | const mixedBrowser = targets.some( | 78 | |
| 95 | (target) => | 79 | // A checkout's t3.json wins over the environment default when the project |
| 96 | target.serverConfig?.settings.enableAgentBrowserAccess !== | 80 | // has no override of its own; show which one "inherit" resolves to. |
| 97 | serverSettings.enableAgentBrowserAccess, | 81 | const checkout = scope.kind === "checkout" ? scope.checkout : null; |
| 98 | ); | 82 | // The query is disabled without a checkout, so any id satisfies the hook. |
| 99 | const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); | 83 | const t3File = useT3ProjectFileState( |
| 100 | const mixedAutoPull = targets.some( | 84 | checkout?.environmentId ?? EnvironmentId.make("none"), |
| 101 | (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, | 85 | category === "general" && checkout ? checkout.workspaceRoot : null, |
| 102 | ); | 86 | ); |
| 87 | const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; | ||
| 88 | const inheritedEnvModeLabel = | ||
| 89 | workspaceSource === "project" | ||
| 90 | ? null | ||
| 91 | : repositoryEnvMode | ||
| 92 | ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` | ||
| 93 | : null; | ||
| 103 | 94 | ||
| 104 | function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { | 95 | function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { |
| 105 | const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); | 96 | const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); |
| 106 | for (const target of targets) { | 97 | for (const candidate of targets) { |
| 107 | const config = target.serverConfig; | 98 | const environment = environments.find( |
| 99 | (entry) => entry.environmentId === candidate.environmentId, | ||
| 100 | ); | ||
| 101 | const config = environment?.serverConfig; | ||
| 108 | if (!config) continue; | 102 | if (!config) continue; |
| 109 | const entry = applyProviderInstanceSettings( | 103 | const entry = applyProviderInstanceSettings( |
| 110 | deriveProviderInstanceEntries(config.providers), | 104 | deriveProviderInstanceEntries(config.providers), |
| 111 | config.settings, | 105 | candidate.settings, |
| 112 | ).find((candidate) => candidate.instanceId === instanceId); | 106 | ).find((option) => option.instanceId === instanceId); |
| 113 | const options = getCustomModelOptionsByInstance( | 107 | const options = getCustomModelOptionsByInstance( |
| 114 | { ...config.settings, ...clientSettings }, | 108 | { ...settings, ...candidate.settings }, |
| 115 | config.providers, | 109 | config.providers, |
| 116 | ).get(instanceId); | 110 | ).get(instanceId); |
| 117 | if ( | 111 | if ( |
| ... | @@ -120,355 +114,282 @@ export function ProjectDefaultsSettings({ | ... | @@ -120,355 +114,282 @@ export function ProjectDefaultsSettings({ |
| 120 | entry.driverKind !== sourceEntry?.driverKind || | 114 | entry.driverKind !== sourceEntry?.driverKind || |
| 121 | !options?.some((option) => option.slug === model && !option.isUnavailable) | 115 | !options?.some((option) => option.slug === model && !option.isUnavailable) |
| 122 | ) { | 116 | ) { |
| 123 | return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; | 117 | return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`; |
| 124 | } | 118 | } |
| 125 | } | 119 | } |
| 126 | return null; | 120 | return null; |
| 127 | } | 121 | } |
| 128 | 122 | ||
| 129 | async function save(patch: ServerSettingsPatch) { | 123 | const setModel = (value: ModelSelection | null) => { |
| 130 | const keys = Object.keys(patch); | 124 | const reason = value ? modelDisabledReason(value.instanceId, value.model) : null; |
| 131 | if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; | ||
| 132 | const nextModel = patch.defaultModelSelection; | ||
| 133 | const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; | ||
| 134 | if (reason) { | 125 | if (reason) { |
| 135 | toastManager.add({ type: "error", title: "Default model not saved", description: reason }); | 126 | toastManager.add({ type: "error", title: "Default model not saved", description: reason }); |
| 136 | return; | 127 | return; |
| 137 | } | 128 | } |
| 138 | for (const key of keys) savingRef.current.add(key); | 129 | updateSettings({ defaultModelSelection: value }); |
| 139 | setSaving(new Set(savingRef.current)); | 130 | }; |
| 140 | try { | ||
| 141 | const results = await Promise.all( | ||
| 142 | targets.map((target) => | ||
| 143 | updateSettings({ environmentId: target.environmentId, input: { patch } }), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); | ||
| 147 | if (failedTargets.length > 0) { | ||
| 148 | toastManager.add({ | ||
| 149 | type: "error", | ||
| 150 | title: "Project defaults not saved on every machine", | ||
| 151 | description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | } finally { | ||
| 155 | for (const key of keys) savingRef.current.delete(key); | ||
| 156 | setSaving(new Set(savingRef.current)); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | 131 | ||
| 160 | const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); | ||
| 161 | return ( | 132 | return ( |
| 162 | <SettingsPageContainer> | 133 | <SettingsSection |
| 163 | <SettingsSection | 134 | id={ |
| 164 | id={searchableSetting("project-defaults").id} | 135 | category === "general" |
| 165 | title="Project defaults" | 136 | ? "project-defaults" |
| 166 | hideTitle | 137 | : category === "integrations" |
| 167 | > | 138 | ? "browser-access" |
| 168 | <SettingsRow | 139 | : "source-control-defaults" |
| 169 | title="Name" | 140 | } |
| 170 | aria-disabled | 141 | title={ |
| 171 | description="Select a project to change its name." | 142 | category === "general" |
| 172 | control={ | 143 | ? "New threads" |
| 173 | <Input | 144 | : category === "integrations" |
| 174 | size="sm" | 145 | ? "Browser" |
| 175 | className="w-full sm:w-64" | 146 | : "Repositories" |
| 176 | aria-label="Project name" | 147 | } |
| 177 | placeholder="Select a project" | 148 | > |
| 178 | disabled | 149 | {category === "general" ? ( |
| 179 | /> | 150 | <> |
| 180 | } | 151 | <SettingsRow |
| 181 | /> | 152 | serverScoped |
| 182 | <SettingsRow | 153 | settingKeys={["defaultModelSelection"]} |
| 183 | title="Project icon" | 154 | mixed={mixedModel} |
| 184 | aria-disabled | 155 | id="default-model" |
| 185 | description="Select a project to change its icon." | 156 | title="Model" |
| 186 | control={ | 157 | description={ |
| 187 | <div className="flex items-center gap-2"> | 158 | isProjectScope |
| 188 | <Button size="sm" variant="outline" disabled> | 159 | ? "Model for new threads in this project." |
| 189 | Choose icon | 160 | : "Default model for new threads. Projects can override it." |
| 190 | </Button> | 161 | } |
| 191 | <Button size="sm" variant="outline" disabled> | 162 | status={ |
| 192 | Choose file | 163 | unavailable || mixedModel || modelSource === "project" |
| 193 | </Button> | 164 | ? undefined |
| 194 | </div> | 165 | : settings.defaultModelSelection === null |
| 195 | } | ||
| 196 | /> | ||
| 197 | {scoped.length > targets.length || targets.length === 0 ? ( | ||
| 198 | <p role="status" className="px-4 py-3 text-sm text-muted-foreground"> | ||
| 199 | {targets.length === 0 | ||
| 200 | ? "Connect a machine to change its project defaults." | ||
| 201 | : "Changes apply to connected machines only. Offline machines keep their current defaults."} | ||
| 202 | </p> | ||
| 203 | ) : null} | ||
| 204 | <SettingsRow | ||
| 205 | title="Model" | ||
| 206 | description="Default model for new threads. Projects can override it." | ||
| 207 | status={ | ||
| 208 | targets.length === 0 | ||
| 209 | ? undefined | ||
| 210 | : mixedModel | ||
| 211 | ? "Differs by machine" | ||
| 212 | : storedSelection === null | ||
| 213 | ? "Automatic" | 166 | ? "Automatic" |
| 214 | : undefined | 167 | : undefined |
| 215 | } | 168 | } |
| 216 | resetAction={ | 169 | resetAction={ |
| 217 | storedSelection !== null || mixedModel ? ( | 170 | settings.defaultModelSelection !== null ? ( |
| 218 | <SettingResetButton | 171 | <SettingResetButton label="default model" onClick={() => setModel(null)} /> |
| 219 | label="default model" | 172 | ) : null |
| 220 | disabled={disabled("defaultModelSelection")} | 173 | } |
| 221 | onClick={() => setModel(null)} | 174 | control={ |
| 222 | /> | 175 | selection && activeEntry ? ( |
| 223 | ) : null | 176 | <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5"> |
| 224 | } | 177 | <ProviderModelPicker |
| 225 | control={ | 178 | activeInstanceId={selection.instanceId} |
| 226 | selection && activeEntry ? ( | ||
| 227 | <fieldset | ||
| 228 | disabled={disabled("defaultModelSelection")} | ||
| 229 | className="flex min-w-0 flex-wrap items-center justify-end gap-1.5 disabled:opacity-50" | ||
| 230 | > | ||
| 231 | <ProviderModelPicker | ||
| 232 | activeInstanceId={selection.instanceId} | ||
| 233 | model={selection.model} | ||
| 234 | lockedProvider={null} | ||
| 235 | instanceEntries={entries} | ||
| 236 | modelOptionsByInstance={modelOptions} | ||
| 237 | disabled={disabled("defaultModelSelection")} | ||
| 238 | triggerVariant="outline" | ||
| 239 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | ||
| 240 | getModelDisabledReason={modelDisabledReason} | ||
| 241 | onOpenProviderSetup={(instanceId) => { | ||
| 242 | if (representative) | ||
| 243 | void navigate({ | ||
| 244 | to: "/settings/providers", | ||
| 245 | search: { environmentId: representative.environmentId, instanceId }, | ||
| 246 | }); | ||
| 247 | }} | ||
| 248 | onInstanceModelChange={(instanceId, model) => | ||
| 249 | setModel(createModelSelection(instanceId, model)) | ||
| 250 | } | ||
| 251 | /> | ||
| 252 | {!mixedModel ? ( | ||
| 253 | <TraitsPicker | ||
| 254 | provider={activeEntry.driverKind} | ||
| 255 | models={activeEntry.models} | ||
| 256 | model={selection.model} | 179 | model={selection.model} |
| 257 | prompt="" | 180 | lockedProvider={null} |
| 258 | onPromptChange={() => {}} | 181 | instanceEntries={entries} |
| 259 | modelOptions={selection.options ?? []} | 182 | modelOptionsByInstance={modelOptions} |
| 260 | allowPromptInjectedEffort={false} | ||
| 261 | planModeEnabled={settings.planModeEnabled} | ||
| 262 | triggerVariant="outline" | 183 | triggerVariant="outline" |
| 263 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | 184 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} |
| 264 | onModelOptionsChange={(options) => | 185 | {...(mixedModel ? { triggerLabel: "Mixed" } : {})} |
| 265 | setModel(createModelSelection(selection.instanceId, selection.model, options)) | 186 | getModelDisabledReason={modelDisabledReason} |
| 187 | onOpenProviderSetup={(instanceId) => { | ||
| 188 | if (representative) | ||
| 189 | void navigate({ | ||
| 190 | to: "/settings/providers", | ||
| 191 | search: { environmentId: representative.environmentId, instanceId }, | ||
| 192 | }); | ||
| 193 | }} | ||
| 194 | onInstanceModelChange={(instanceId, model) => | ||
| 195 | setModel(createModelSelection(instanceId, model)) | ||
| 266 | } | 196 | } |
| 267 | /> | 197 | /> |
| 268 | ) : null} | 198 | {!mixedModel ? ( |
| 269 | </fieldset> | 199 | <TraitsPicker |
| 270 | ) : ( | 200 | provider={activeEntry.driverKind} |
| 271 | <span className="text-sm text-muted-foreground">No providers available</span> | 201 | models={activeEntry.models} |
| 272 | ) | 202 | model={selection.model} |
| 273 | } | 203 | prompt="" |
| 274 | /> | 204 | onPromptChange={() => {}} |
| 275 | <SettingsRow | 205 | modelOptions={selection.options ?? []} |
| 276 | id={searchableSetting("new-threads").id} | 206 | allowPromptInjectedEffort={false} |
| 277 | title="Workspace" | 207 | planModeEnabled={settings.planModeEnabled} |
| 278 | description="Where new threads start, unless overridden by the project or t3.json." | 208 | triggerVariant="outline" |
| 279 | resetAction={ | 209 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} |
| 280 | mixedWorkspace || | 210 | onModelOptionsChange={(options) => |
| 281 | serverSettings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( | 211 | setModel( |
| 282 | <SettingResetButton | 212 | createModelSelection(selection.instanceId, selection.model, options), |
| 283 | label="default workspace" | 213 | ) |
| 284 | disabled={disabled("defaultThreadEnvMode")} | 214 | } |
| 285 | onClick={() => | 215 | /> |
| 286 | void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) | 216 | ) : null} |
| 287 | } | 217 | </div> |
| 288 | /> | 218 | ) : ( |
| 289 | ) : null | 219 | <span className="text-sm text-muted-foreground">No providers available</span> |
| 290 | } | 220 | ) |
| 291 | control={ | 221 | } |
| 292 | <Select | 222 | /> |
| 293 | disabled={disabled("defaultThreadEnvMode")} | 223 | <SettingsRow |
| 294 | value={mixedWorkspace ? "mixed" : serverSettings.defaultThreadEnvMode} | 224 | serverScoped |
| 295 | onValueChange={(value) => { | 225 | settingKeys={["defaultThreadEnvMode"]} |
| 296 | if (value === "local" || value === "worktree") | 226 | mixed={mixedWorkspace} |
| 297 | void save({ defaultThreadEnvMode: value }); | 227 | id={searchableSetting("new-threads").id} |
| 298 | }} | 228 | title="Workspace" |
| 299 | > | 229 | description={ |
| 300 | <SelectTrigger size="sm" aria-label="Default workspace"> | 230 | isProjectScope |
| 301 | <SelectValue> | 231 | ? "Where new threads in this project start. A t3.json preference applies when the project has no override." |
| 302 | {targets.length === 0 | 232 | : "Where new threads start, unless overridden by the project or t3.json." |
| 303 | ? "Unavailable" | 233 | } |
| 304 | : mixedWorkspace | 234 | status={ |
| 305 | ? "Differs by machine" | 235 | inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined |
| 306 | : resolveEnvModeLabel(serverSettings.defaultThreadEnvMode)} | 236 | } |
| 307 | </SelectValue> | 237 | resetAction={ |
| 308 | </SelectTrigger> | 238 | settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( |
| 309 | <SelectPopup align="end" alignItemWithTrigger={false}> | 239 | <SettingResetButton |
| 310 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | 240 | label="default workspace" |
| 311 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | 241 | onClick={() => |
| 312 | </SelectPopup> | 242 | updateSettings({ |
| 313 | </Select> | 243 | defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, |
| 314 | } | 244 | }) |
| 315 | /> | 245 | } |
| 316 | <SettingsRow | 246 | /> |
| 317 | title="Automatically pull" | 247 | ) : null |
| 318 | description="Keeps the default branch current when the checkout has no local changes or commits. Projects can override it." | 248 | } |
| 319 | status={mixedAutoPull ? "Differs by machine" : undefined} | 249 | control={ |
| 320 | resetAction={ | 250 | <Select |
| 321 | serverSettings.defaultAutoPull || mixedAutoPull ? ( | 251 | value={mixedWorkspace ? null : settings.defaultThreadEnvMode} |
| 322 | <SettingResetButton | 252 | onValueChange={(value) => { |
| 323 | label="default automatic pull" | 253 | if (value === "local" || value === "worktree") |
| 324 | tooltip="Reset automatic pull to off" | 254 | updateSettings({ defaultThreadEnvMode: value }); |
| 325 | disabled={disabled("defaultAutoPull")} | 255 | }} |
| 326 | onClick={() => void save({ defaultAutoPull: false })} | 256 | > |
| 257 | <SelectTrigger size="sm" aria-label="Default workspace"> | ||
| 258 | <SelectValue> | ||
| 259 | {(value: string | null) => | ||
| 260 | value === "local" || value === "worktree" | ||
| 261 | ? resolveEnvModeLabel(value) | ||
| 262 | : unavailable | ||
| 263 | ? "Unavailable" | ||
| 264 | : "Mixed" | ||
| 265 | } | ||
| 266 | </SelectValue> | ||
| 267 | </SelectTrigger> | ||
| 268 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 269 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | ||
| 270 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | ||
| 271 | </SelectPopup> | ||
| 272 | </Select> | ||
| 273 | } | ||
| 274 | /> | ||
| 275 | </> | ||
| 276 | ) : category === "source-control" ? ( | ||
| 277 | <> | ||
| 278 | <SettingsRow | ||
| 279 | serverScoped | ||
| 280 | settingKeys={["defaultAutoPull"]} | ||
| 281 | mixed={mixedAutoPull} | ||
| 282 | id="automatic-pull" | ||
| 283 | title="Automatically pull" | ||
| 284 | description={ | ||
| 285 | isProjectScope | ||
| 286 | ? "Keeps this project's default branch current when the checkout has no local changes or commits." | ||
| 287 | : "Keeps the default branch current when the checkout has no local changes or commits. Projects can override it." | ||
| 288 | } | ||
| 289 | resetAction={ | ||
| 290 | settings.defaultAutoPull ? ( | ||
| 291 | <SettingResetButton | ||
| 292 | label="default automatic pull" | ||
| 293 | tooltip="Reset automatic pull to off" | ||
| 294 | onClick={() => updateSettings({ defaultAutoPull: false })} | ||
| 295 | /> | ||
| 296 | ) : null | ||
| 297 | } | ||
| 298 | control={ | ||
| 299 | <Switch | ||
| 300 | aria-label="Default automatic pull" | ||
| 301 | mixed={mixedAutoPull} | ||
| 302 | checked={mixedAutoPull ? false : settings.defaultAutoPull} | ||
| 303 | onCheckedChange={(enabled) => updateSettings({ defaultAutoPull: enabled })} | ||
| 327 | /> | 304 | /> |
| 328 | ) : null | 305 | } |
| 329 | } | 306 | /> |
| 330 | control={ | 307 | <SettingsRow |
| 331 | <Switch | 308 | serverScoped |
| 332 | aria-label="Default automatic pull" | 309 | settingKeys={["pullRequestMergeMethod"]} |
| 333 | checked={serverSettings.defaultAutoPull} | 310 | mixed={mixedMergeMethod} |
| 334 | disabled={disabled("defaultAutoPull")} | 311 | {...searchableSetting("pull-request-merge-method")} |
| 335 | onCheckedChange={(enabled) => void save({ defaultAutoPull: enabled })} | 312 | description={ |
| 336 | /> | 313 | isProjectScope |
| 337 | } | 314 | ? "Pull requests in this project start with this method." |
| 338 | /> | 315 | : "Pull requests start with this method. Last selected reuses whatever you chose most recently on this device." |
| 339 | <SettingsRow | 316 | } |
| 340 | id={searchableSetting("agent-browser-access").id} | 317 | resetAction={ |
| 341 | title="Agent browser access" | 318 | settings.pullRequestMergeMethod !== null ? ( |
| 342 | description="Allow agents to use the shared browser. Projects can override it." | 319 | <SettingResetButton |
| 343 | resetAction={ | 320 | label="default merge method" |
| 344 | mixedBrowser || | 321 | tooltip="Reset to last selected" |
| 345 | serverSettings.enableAgentBrowserAccess !== | 322 | onClick={() => updateSettings({ pullRequestMergeMethod: null })} |
| 323 | /> | ||
| 324 | ) : null | ||
| 325 | } | ||
| 326 | control={ | ||
| 327 | <Select | ||
| 328 | value={mixedMergeMethod ? null : (settings.pullRequestMergeMethod ?? "last")} | ||
| 329 | onValueChange={(value) => { | ||
| 330 | if (value === "last") updateSettings({ pullRequestMergeMethod: null }); | ||
| 331 | else if (value === "merge" || value === "squash" || value === "rebase") | ||
| 332 | updateSettings({ pullRequestMergeMethod: value }); | ||
| 333 | }} | ||
| 334 | > | ||
| 335 | <SelectTrigger size="sm" aria-label="Default pull request merge method"> | ||
| 336 | <SelectValue> | ||
| 337 | {(value: string | null) => | ||
| 338 | value === "merge" || value === "squash" || value === "rebase" | ||
| 339 | ? PULL_REQUEST_MERGE_METHOD_LABELS[value] | ||
| 340 | : value === "last" | ||
| 341 | ? "Last selected" | ||
| 342 | : "Mixed" | ||
| 343 | } | ||
| 344 | </SelectValue> | ||
| 345 | </SelectTrigger> | ||
| 346 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 347 | <SelectItem value="last">Last selected</SelectItem> | ||
| 348 | <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem> | ||
| 349 | <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem> | ||
| 350 | <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem> | ||
| 351 | </SelectPopup> | ||
| 352 | </Select> | ||
| 353 | } | ||
| 354 | /> | ||
| 355 | </> | ||
| 356 | ) : ( | ||
| 357 | <> | ||
| 358 | <SettingsRow | ||
| 359 | serverScoped | ||
| 360 | settingKeys={["enableAgentBrowserAccess"]} | ||
| 361 | mixed={mixedBrowser} | ||
| 362 | id={searchableSetting("agent-browser-access").id} | ||
| 363 | title="Agent browser access" | ||
| 364 | description={ | ||
| 365 | isProjectScope | ||
| 366 | ? "Allow agents in this project to use the shared browser. Applies when the agent session next starts." | ||
| 367 | : "Allow agents to use the shared browser. Projects can override it." | ||
| 368 | } | ||
| 369 | resetAction={ | ||
| 370 | settings.enableAgentBrowserAccess !== | ||
| 346 | DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? ( | 371 | DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? ( |
| 347 | <SettingResetButton | 372 | <SettingResetButton |
| 348 | label="default browser access" | 373 | label="default browser access" |
| 349 | disabled={disabled("enableAgentBrowserAccess")} | 374 | onClick={() => |
| 350 | onClick={() => | 375 | updateSettings({ |
| 351 | void save({ | 376 | enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, |
| 352 | enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, | 377 | }) |
| 353 | }) | 378 | } |
| 354 | } | 379 | /> |
| 355 | /> | 380 | ) : null |
| 356 | ) : null | 381 | } |
| 357 | } | 382 | control={ |
| 358 | control={ | 383 | <Switch |
| 359 | <Select | 384 | aria-label="Agent browser access" |
| 360 | disabled={disabled("enableAgentBrowserAccess")} | 385 | mixed={mixedBrowser} |
| 361 | value={ | 386 | checked={mixedBrowser ? false : settings.enableAgentBrowserAccess} |
| 362 | mixedBrowser | 387 | onCheckedChange={(enabled) => updateSettings({ enableAgentBrowserAccess: enabled })} |
| 363 | ? "mixed" | ||
| 364 | : serverSettings.enableAgentBrowserAccess | ||
| 365 | ? "enabled" | ||
| 366 | : "disabled" | ||
| 367 | } | ||
| 368 | onValueChange={(value) => { | ||
| 369 | if (value === "enabled" || value === "disabled") | ||
| 370 | void save({ enableAgentBrowserAccess: value === "enabled" }); | ||
| 371 | }} | ||
| 372 | > | ||
| 373 | <SelectTrigger size="sm" aria-label="Default agent browser access"> | ||
| 374 | <SelectValue> | ||
| 375 | {targets.length === 0 | ||
| 376 | ? "Unavailable" | ||
| 377 | : mixedBrowser | ||
| 378 | ? "Differs by machine" | ||
| 379 | : serverSettings.enableAgentBrowserAccess | ||
| 380 | ? "Enabled" | ||
| 381 | : "Disabled"} | ||
| 382 | </SelectValue> | ||
| 383 | </SelectTrigger> | ||
| 384 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 385 | <SelectItem value="enabled">Enabled</SelectItem> | ||
| 386 | <SelectItem value="disabled">Disabled</SelectItem> | ||
| 387 | </SelectPopup> | ||
| 388 | </Select> | ||
| 389 | } | ||
| 390 | /> | ||
| 391 | </SettingsSection> | ||
| 392 | <SettingsSection title="Checkout"> | ||
| 393 | <SettingsRow | ||
| 394 | title="Checkout" | ||
| 395 | aria-disabled | ||
| 396 | description="Select a project to choose one of its checkouts." | ||
| 397 | control={ | ||
| 398 | <Select disabled> | ||
| 399 | <SelectTrigger size="sm" aria-label="Checkout"> | ||
| 400 | <SelectValue placeholder="Select a project" /> | ||
| 401 | </SelectTrigger> | ||
| 402 | </Select> | ||
| 403 | } | ||
| 404 | /> | ||
| 405 | <SettingsRow | ||
| 406 | title="Project grouping" | ||
| 407 | description="Default grouping across all machines in this client. Individual checkout overrides are preserved." | ||
| 408 | resetAction={ | ||
| 409 | clientSettings.sidebarProjectGroupingMode !== | ||
| 410 | DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode ? ( | ||
| 411 | <SettingResetButton | ||
| 412 | label="default project grouping" | ||
| 413 | onClick={() => | ||
| 414 | void updateClientSettings({ | ||
| 415 | sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, | ||
| 416 | }) | ||
| 417 | } | ||
| 418 | /> | 388 | /> |
| 419 | ) : null | 389 | } |
| 420 | } | 390 | /> |
| 421 | control={ | 391 | </> |
| 422 | <Select | 392 | )} |
| 423 | value={clientSettings.sidebarProjectGroupingMode} | 393 | </SettingsSection> |
| 424 | onValueChange={(value) => { | ||
| 425 | if (value === "repository" || value === "repository_path" || value === "separate") | ||
| 426 | void updateClientSettings({ sidebarProjectGroupingMode: value }); | ||
| 427 | }} | ||
| 428 | > | ||
| 429 | <SelectTrigger size="sm" aria-label="Default project grouping"> | ||
| 430 | <SelectValue> | ||
| 431 | {PROJECT_GROUPING_MODE_LABELS[clientSettings.sidebarProjectGroupingMode]} | ||
| 432 | </SelectValue> | ||
| 433 | </SelectTrigger> | ||
| 434 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 435 | <SelectItem value="repository"> | ||
| 436 | {PROJECT_GROUPING_MODE_LABELS.repository} | ||
| 437 | </SelectItem> | ||
| 438 | <SelectItem value="repository_path"> | ||
| 439 | {PROJECT_GROUPING_MODE_LABELS.repository_path} | ||
| 440 | </SelectItem> | ||
| 441 | <SelectItem value="separate">{PROJECT_GROUPING_MODE_LABELS.separate}</SelectItem> | ||
| 442 | </SelectPopup> | ||
| 443 | </Select> | ||
| 444 | } | ||
| 445 | /> | ||
| 446 | <SettingsRow | ||
| 447 | title="Remove checkout" | ||
| 448 | aria-disabled | ||
| 449 | description="Select a project to remove one of its checkouts." | ||
| 450 | control={ | ||
| 451 | <Button size="sm" variant="destructive-outline" disabled> | ||
| 452 | <Trash2Icon className="size-3.5" /> | ||
| 453 | Remove checkout | ||
| 454 | </Button> | ||
| 455 | } | ||
| 456 | /> | ||
| 457 | </SettingsSection> | ||
| 458 | <ProjectDefaultActionsSettings environmentId={environmentId} /> | ||
| 459 | <SettingsSection title="Danger"> | ||
| 460 | <SettingsRow | ||
| 461 | title="Remove project" | ||
| 462 | aria-disabled | ||
| 463 | description="Select a project to remove its entries and threads. Files on disk are not touched." | ||
| 464 | control={ | ||
| 465 | <Button size="sm" variant="destructive-outline" disabled> | ||
| 466 | <Trash2Icon /> | ||
| 467 | Remove project | ||
| 468 | </Button> | ||
| 469 | } | ||
| 470 | /> | ||
| 471 | </SettingsSection> | ||
| 472 | </SettingsPageContainer> | ||
| 473 | ); | 394 | ); |
| 474 | } | 395 | } |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+54-977| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | 1 | import { |
| 3 | isAtomCommandInterrupted, | 2 | isAtomCommandInterrupted, |
| 4 | mapAtomCommandResult, | 3 | mapAtomCommandResult, |
| ... | @@ -8,102 +7,28 @@ import { | ... | @@ -8,102 +7,28 @@ import { |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 7 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 8 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | 9 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | import { | 10 | import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts"; |
| 12 | deriveProjectGroupingOverrideKey, | 11 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 13 | selectProjectGroupingSettings, | ||
| 14 | } from "../../logicalProject"; | ||
| 15 | import { | ||
| 16 | type EnvironmentId, | ||
| 17 | type ModelSelection, | ||
| 18 | type ProjectIconOverride, | ||
| 19 | type ProjectId, | ||
| 20 | type ProjectScript, | ||
| 21 | type ResolvedKeybindingsConfig, | ||
| 22 | type ServerSettings, | ||
| 23 | type ProviderDriverKind, | ||
| 24 | type PullRequestMergeMethod, | ||
| 25 | type SidebarProjectGroupingMode, | ||
| 26 | type T3ProjectFileScript, | ||
| 27 | type ThreadEnvMode, | ||
| 28 | } from "@t3tools/contracts"; | ||
| 29 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; | ||
| 30 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 31 | import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; | ||
| 32 | import { | ||
| 33 | projectScriptsInheritDefaults, | ||
| 34 | resolveProjectScripts, | ||
| 35 | } from "@t3tools/shared/projectScripts"; | ||
| 36 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ||
| 37 | import { useNavigate } from "@tanstack/react-router"; | ||
| 38 | import * as Equal from "effect/Equal"; | ||
| 39 | import * as Cause from "effect/Cause"; | 12 | import * as Cause from "effect/Cause"; |
| 40 | import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; | 13 | import { Trash2Icon } from "lucide-react"; |
| 41 | import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 14 | import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 42 | 15 | ||
| 43 | import { useComposerDraftStore } from "../../composerDraftStore"; | 16 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 44 | import { | ||
| 45 | useClientSettings, | ||
| 46 | useEnvironmentSettings, | ||
| 47 | useUpdateClientSettings, | ||
| 48 | } from "../../hooks/useSettings"; | ||
| 49 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; | ||
| 50 | import { ProjectActionsList } from "./ProjectActionsList"; | ||
| 51 | import { isElectron } from "../../env"; | ||
| 52 | import { | ||
| 53 | decodeProjectScriptKeybindingRule, | ||
| 54 | keybindingValueForCommand, | ||
| 55 | } from "../../lib/projectScriptKeybindings"; | ||
| 56 | import { | ||
| 57 | buildProjectScript, | ||
| 58 | commandForProjectScript, | ||
| 59 | nextProjectScriptId, | ||
| 60 | } from "../../projectScripts"; | ||
| 61 | import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; | 17 | import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; |
| 62 | import { readLocalApi } from "../../localApi"; | 18 | import { readLocalApi } from "../../localApi"; |
| 63 | import { | ||
| 64 | applyProviderInstanceSettings, | ||
| 65 | deriveProviderInstanceEntries, | ||
| 66 | resolveDefaultProviderModelSelection, | ||
| 67 | sortProviderInstanceEntries, | ||
| 68 | } from "../../providerInstances"; | ||
| 69 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | ||
| 70 | import { | 19 | import { |
| 71 | type SidebarProjectGroupMember, | 20 | type SidebarProjectGroupMember, |
| 72 | type SidebarProjectSnapshot, | 21 | type SidebarProjectSnapshot, |
| 73 | } from "../../sidebarProjectGrouping"; | 22 | } from "../../sidebarProjectGrouping"; |
| 74 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; | 23 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; |
| 75 | import { useProjects, useThreadShells } from "../../state/entities"; | 24 | import { useThreadShells } from "../../state/entities"; |
| 76 | import { projectEnvironment } from "../../state/projects"; | 25 | import { projectEnvironment } from "../../state/projects"; |
| 77 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | ||
| 78 | import { useAtomCommand } from "../../state/use-atom-command"; | 26 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 79 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | ||
| 80 | import { TraitsPicker } from "../chat/TraitsPicker"; | ||
| 81 | import { ProjectFavicon } from "../ProjectFavicon"; | 27 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 82 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; | ||
| 83 | import { | ||
| 84 | EMPTY_PROJECT_SCRIPT_INPUT, | ||
| 85 | editorRequestForScript, | ||
| 86 | ProjectScriptEditorDialog, | ||
| 87 | ScriptIcon, | ||
| 88 | type NewProjectScriptInput, | ||
| 89 | type ProjectScriptEditorRequest, | ||
| 90 | } from "../projectScriptEditor"; | ||
| 91 | import { Button } from "../ui/button"; | 28 | import { Button } from "../ui/button"; |
| 92 | import { Input } from "../ui/input"; | 29 | import { Input } from "../ui/input"; |
| 93 | import { | ||
| 94 | Menu, | ||
| 95 | MenuGroup, | ||
| 96 | MenuGroupLabel, | ||
| 97 | MenuItem, | ||
| 98 | MenuPopup, | ||
| 99 | MenuSeparator, | ||
| 100 | MenuTrigger, | ||
| 101 | } from "../ui/menu"; | ||
| 102 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 103 | import { Switch } from "../ui/switch"; | ||
| 104 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 30 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 105 | import { | 31 | import { |
| 106 | SETTINGS_PICKER_TRIGGER_CLASSNAME, | ||
| 107 | SettingResetButton, | 32 | SettingResetButton, |
| 108 | SettingsPageContainer, | 33 | SettingsPageContainer, |
| 109 | SettingsRow, | 34 | SettingsRow, |
| ... | @@ -113,6 +38,7 @@ import { | ... | @@ -113,6 +38,7 @@ import { |
| 113 | canPickExternalProjectFavicon, | 38 | canPickExternalProjectFavicon, |
| 114 | ProjectFaviconPickerDialog, | 39 | ProjectFaviconPickerDialog, |
| 115 | } from "./ProjectFaviconPickerDialog"; | 40 | } from "./ProjectFaviconPickerDialog"; |
| 41 | import { ProjectActionsSettings } from "./ProjectActionsSettings"; | ||
| 116 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; | 42 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; |
| 117 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | 43 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; |
| 118 | 44 | ||
| ... | @@ -122,16 +48,12 @@ const ProjectIconPickerDialog = lazy(() => | ... | @@ -122,16 +48,12 @@ const ProjectIconPickerDialog = lazy(() => |
| 122 | })), | 48 | })), |
| 123 | ); | 49 | ); |
| 124 | 50 | ||
| 125 | export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { | ||
| 126 | repository: "Group by repository", | ||
| 127 | repository_path: "Group by repository path", | ||
| 128 | separate: "Keep separate", | ||
| 129 | }; | ||
| 130 | |||
| 131 | function memberKey(member: { environmentId: string; id: string }): string { | 51 | function memberKey(member: { environmentId: string; id: string }): string { |
| 132 | return `${member.environmentId}:${member.id}`; | 52 | return `${member.environmentId}:${member.id}`; |
| 133 | } | 53 | } |
| 134 | 54 | ||
| 55 | export type ProjectSettingsCategory = "general" | "integrations" | "source-control"; | ||
| 56 | |||
| 135 | export function ProjectSettingsPanel({ | 57 | export function ProjectSettingsPanel({ |
| 136 | projectKey, | 58 | projectKey, |
| 137 | environmentId = null, | 59 | environmentId = null, |
| ... | @@ -142,7 +64,8 @@ export function ProjectSettingsPanel({ | ... | @@ -142,7 +64,8 @@ export function ProjectSettingsPanel({ |
| 142 | checkoutKey?: string | null; | 64 | checkoutKey?: string | null; |
| 143 | }) { | 65 | }) { |
| 144 | const groups = useSettingsProjectGroups(); | 66 | const groups = useSettingsProjectGroups(); |
| 145 | const navigate = useNavigate(); | 67 | const navigate = useNavigate({ from: "/settings" }); |
| 68 | const pathname = useLocation({ select: (location) => location.pathname }); | ||
| 146 | 69 | ||
| 147 | const selected = groups.find((group) => group.projectKey === projectKey) ?? null; | 70 | const selected = groups.find((group) => group.projectKey === projectKey) ?? null; |
| 148 | const members = useMemo( | 71 | const members = useMemo( |
| ... | @@ -189,17 +112,17 @@ export function ProjectSettingsPanel({ | ... | @@ -189,17 +112,17 @@ export function ProjectSettingsPanel({ |
| 189 | ); | 112 | ); |
| 190 | if (successor) { | 113 | if (successor) { |
| 191 | void navigate({ | 114 | void navigate({ |
| 192 | to: "/settings/projects", | 115 | to: pathname, |
| 193 | search: { | 116 | search: () => ({ |
| 194 | project: successor.projectKey, | 117 | project: successor.projectKey, |
| 195 | machine: environmentId ?? undefined, | 118 | machine: environmentId ?? undefined, |
| 196 | checkout: checkoutKey ?? undefined, | 119 | checkout: checkoutKey ?? undefined, |
| 197 | }, | 120 | }), |
| 198 | replace: true, | 121 | replace: true, |
| 199 | hashScrollIntoView: false, | 122 | hashScrollIntoView: false, |
| 200 | }); | 123 | }); |
| 201 | } | 124 | } |
| 202 | }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]); | 125 | }, [groups, navigate, pathname, projectKey, members.length, environmentId, checkoutKey]); |
| 203 | 126 | ||
| 204 | if (!selected) { | 127 | if (!selected) { |
| 205 | return ( | 128 | return ( |
| ... | @@ -231,146 +154,6 @@ export function ProjectSettingsPanel({ | ... | @@ -231,146 +154,6 @@ export function ProjectSettingsPanel({ |
| 231 | ); | 154 | ); |
| 232 | } | 155 | } |
| 233 | 156 | ||
| 234 | function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) { | ||
| 235 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 236 | const error = squashAtomCommandFailure(result); | ||
| 237 | toastManager.add({ | ||
| 238 | type: "error", | ||
| 239 | title: "Failed to save project actions", | ||
| 240 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 241 | }); | ||
| 242 | } | ||
| 243 | return mapAtomCommandResult(result, () => undefined); | ||
| 244 | } | ||
| 245 | |||
| 246 | export function useProjectScriptSettings( | ||
| 247 | targets: readonly { | ||
| 248 | environmentId: EnvironmentId; | ||
| 249 | settings: ServerSettings; | ||
| 250 | keybindings: ResolvedKeybindingsConfig; | ||
| 251 | project?: { id: ProjectId; scripts: readonly ProjectScript[] }; | ||
| 252 | }[], | ||
| 253 | ) { | ||
| 254 | const projects = useProjects(); | ||
| 255 | const [saving, setSaving] = useState(false); | ||
| 256 | const savingRef = useRef(false); | ||
| 257 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); | ||
| 258 | const upsertKeybinding = useAtomCommand( | ||
| 259 | serverEnvironment.upsertKeybinding, | ||
| 260 | "action shortcut update", | ||
| 261 | ); | ||
| 262 | const removeKeybinding = useAtomCommand( | ||
| 263 | serverEnvironment.removeKeybinding, | ||
| 264 | "action shortcut removal", | ||
| 265 | ); | ||
| 266 | |||
| 267 | async function persist( | ||
| 268 | transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, | ||
| 269 | scriptId?: string, | ||
| 270 | keybinding?: string | null, | ||
| 271 | ): Promise<AtomCommandResult<void, unknown>> { | ||
| 272 | if (savingRef.current || targets.length === 0) { | ||
| 273 | const message = "No available machine, or another action change is saving."; | ||
| 274 | toastManager.add({ type: "error", title: "Actions not saved", description: message }); | ||
| 275 | return AsyncResult.failure(Cause.fail(new Error(message))); | ||
| 276 | } | ||
| 277 | savingRef.current = true; | ||
| 278 | setSaving(true); | ||
| 279 | try { | ||
| 280 | for (const { environmentId, settings, keybindings, project } of targets) { | ||
| 281 | const current = project | ||
| 282 | ? resolveProjectScripts(settings, project) | ||
| 283 | : settings.defaultProjectScripts; | ||
| 284 | const nextScripts = transform(current); | ||
| 285 | const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; | ||
| 286 | const result = await updateSettings({ | ||
| 287 | environmentId, | ||
| 288 | input: { | ||
| 289 | patch: project | ||
| 290 | ? { projectScriptOverrides: { [project.id]: nextScripts } } | ||
| 291 | : { defaultProjectScripts: nextScripts ?? [] }, | ||
| 292 | }, | ||
| 293 | }); | ||
| 294 | if (result._tag === "Failure") return reportScriptFailure(result); | ||
| 295 | if (!isElectron) continue; | ||
| 296 | const changedIds = scriptId | ||
| 297 | ? [scriptId] | ||
| 298 | : current | ||
| 299 | .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) | ||
| 300 | .map((script) => script.id); | ||
| 301 | for (const id of changedIds) { | ||
| 302 | const command = commandForProjectScript(id); | ||
| 303 | const previousValue = keybindingValueForCommand(keybindings, command); | ||
| 304 | const previous = previousValue | ||
| 305 | ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) | ||
| 306 | : null; | ||
| 307 | const next = decodeProjectScriptKeybindingRule({ keybinding, command }); | ||
| 308 | const retainedElsewhere = | ||
| 309 | !nextScripts?.some((script) => script.id === id) && | ||
| 310 | ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || | ||
| 311 | Object.entries(settings.projectScriptOverrides).some( | ||
| 312 | ([projectId, scripts]) => | ||
| 313 | projectId !== project?.id && scripts?.some((script) => script.id === id), | ||
| 314 | ) || | ||
| 315 | projects.some( | ||
| 316 | (other) => | ||
| 317 | other.environmentId === environmentId && | ||
| 318 | other.id !== project?.id && | ||
| 319 | (project ? resolveProjectScripts(settings, other) : other.scripts).some( | ||
| 320 | (script) => script.id === id, | ||
| 321 | ), | ||
| 322 | )); | ||
| 323 | const bindingResult = next | ||
| 324 | ? await upsertKeybinding({ | ||
| 325 | environmentId, | ||
| 326 | input: | ||
| 327 | previous && previous.key !== next.key ? { ...next, replace: previous } : next, | ||
| 328 | }) | ||
| 329 | : previous && !retainedElsewhere | ||
| 330 | ? await removeKeybinding({ environmentId, input: previous }) | ||
| 331 | : null; | ||
| 332 | if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); | ||
| 333 | } | ||
| 334 | } | ||
| 335 | return AsyncResult.success(undefined); | ||
| 336 | } finally { | ||
| 337 | savingRef.current = false; | ||
| 338 | setSaving(false); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | function submit(scriptId: string | null, input: NewProjectScriptInput) { | ||
| 343 | const existingIds = [ | ||
| 344 | ...projects.flatMap((project) => project.scripts.map((script) => script.id)), | ||
| 345 | ...targets.flatMap(({ settings, project }) => | ||
| 346 | [ | ||
| 347 | ...settings.defaultProjectScripts, | ||
| 348 | ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), | ||
| 349 | ...(project?.scripts ?? []), | ||
| 350 | ].map((script) => script.id), | ||
| 351 | ), | ||
| 352 | ]; | ||
| 353 | const id = scriptId ?? nextProjectScriptId(input.name, existingIds); | ||
| 354 | const next = buildProjectScript(id, input); | ||
| 355 | return persist( | ||
| 356 | (current) => { | ||
| 357 | const updated = current.map((script) => | ||
| 358 | script.id === id | ||
| 359 | ? next | ||
| 360 | : input.runOnWorktreeCreate | ||
| 361 | ? { ...script, runOnWorktreeCreate: false } | ||
| 362 | : script, | ||
| 363 | ); | ||
| 364 | return scriptId === null ? [...updated, next] : updated; | ||
| 365 | }, | ||
| 366 | id, | ||
| 367 | input.keybinding, | ||
| 368 | ); | ||
| 369 | } | ||
| 370 | |||
| 371 | return { saving, persist, submit }; | ||
| 372 | } | ||
| 373 | |||
| 374 | function ProjectDetail({ | 157 | function ProjectDetail({ |
| 375 | group, | 158 | group, |
| 376 | hasOtherMembers, | 159 | hasOtherMembers, |
| ... | @@ -378,7 +161,7 @@ function ProjectDetail({ | ... | @@ -378,7 +161,7 @@ function ProjectDetail({ |
| 378 | group: SidebarProjectSnapshot; | 161 | group: SidebarProjectSnapshot; |
| 379 | hasOtherMembers: boolean; | 162 | hasOtherMembers: boolean; |
| 380 | }) { | 163 | }) { |
| 381 | const navigate = useNavigate(); | 164 | const navigate = useNavigate({ from: "/settings" }); |
| 382 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 165 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 383 | const { environments } = useEnvironments(); | 166 | const { environments } = useEnvironments(); |
| 384 | const environmentById = useMemo( | 167 | const environmentById = useMemo( |
| ... | @@ -389,102 +172,10 @@ function ProjectDetail({ | ... | @@ -389,102 +172,10 @@ function ProjectDetail({ |
| 389 | group.memberProjects.find( | 172 | group.memberProjects.find( |
| 390 | (member) => environmentById.get(member.environmentId)?.serverConfig != null, | 173 | (member) => environmentById.get(member.environmentId)?.serverConfig != null, |
| 391 | ) ?? group.memberProjects[0]!; | 174 | ) ?? group.memberProjects[0]!; |
| 392 | // Provider instances and model options belong to the environment that runs | ||
| 393 | // the project's threads. The hosted app has no primary environment, so | ||
| 394 | // reading them from there would show "No providers available" everywhere. | ||
| 395 | const projectSettings = useEnvironmentSettings(representative.environmentId); | ||
| 396 | const serverProviders = | ||
| 397 | useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? | ||
| 398 | EMPTY_SERVER_PROVIDERS; | ||
| 399 | const updateClientSettings = useUpdateClientSettings(); | ||
| 400 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | ||
| 401 | const threads = useThreadShells(); | 175 | const threads = useThreadShells(); |
| 402 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); | 176 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); |
| 403 | const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); | ||
| 404 | const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); | ||
| 405 | const savingBrowserAccessRef = useRef(false); | ||
| 406 | const browserOverrides = group.memberProjects.map( | ||
| 407 | (member) => | ||
| 408 | environmentById.get(member.environmentId)?.serverConfig?.settings | ||
| 409 | .projectAgentBrowserAccessOverrides[member.id], | ||
| 410 | ); | ||
| 411 | const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; | ||
| 412 | const browserMixed = group.memberProjects.some((member, index) => { | ||
| 413 | const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; | ||
| 414 | if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; | ||
| 415 | return ( | ||
| 416 | browserOverrides[index] !== browserOverride || | ||
| 417 | (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== | ||
| 418 | (browserOverride ?? projectSettings.enableAgentBrowserAccess) | ||
| 419 | ); | ||
| 420 | }); | ||
| 421 | const setBooleanOverride = async ( | ||
| 422 | key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", | ||
| 423 | enabled: boolean | undefined, | ||
| 424 | ) => { | ||
| 425 | if (savingBrowserAccessRef.current) return; | ||
| 426 | savingBrowserAccessRef.current = true; | ||
| 427 | setSavingBrowserAccess(true); | ||
| 428 | try { | ||
| 429 | const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); | ||
| 430 | for (const environmentId of environmentIds) { | ||
| 431 | const environment = environmentById.get(environmentId); | ||
| 432 | if (!environment?.serverConfig || environment.connection.phase !== "connected") { | ||
| 433 | toastManager.add({ | ||
| 434 | type: "warning", | ||
| 435 | title: "Setting not saved", | ||
| 436 | description: `Connect ${environment?.label ?? "this machine"} and try again.`, | ||
| 437 | }); | ||
| 438 | return; | ||
| 439 | } | ||
| 440 | } | ||
| 441 | if (key === "projectAutoPullOverrides" && enabled === undefined) { | ||
| 442 | const result = await updateAllMembers( | ||
| 443 | { autoPull: false }, | ||
| 444 | "Failed to reset automatic pull", | ||
| 445 | ); | ||
| 446 | if (result._tag === "Failure") return; | ||
| 447 | } | ||
| 448 | for (const environmentId of environmentIds) { | ||
| 449 | const overrides = Object.fromEntries( | ||
| 450 | group.memberProjects | ||
| 451 | .filter((member) => member.environmentId === environmentId) | ||
| 452 | .map((member) => [member.id, enabled ?? null]), | ||
| 453 | ); | ||
| 454 | const result = await updateServerSettings({ | ||
| 455 | environmentId, | ||
| 456 | input: { patch: { [key]: overrides } }, | ||
| 457 | }); | ||
| 458 | if (result._tag === "Failure") { | ||
| 459 | reportFailure( | ||
| 460 | `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, | ||
| 461 | mapAtomCommandResult(result, () => undefined), | ||
| 462 | ); | ||
| 463 | return; | ||
| 464 | } | ||
| 465 | } | ||
| 466 | } finally { | ||
| 467 | savingBrowserAccessRef.current = false; | ||
| 468 | setSavingBrowserAccess(false); | ||
| 469 | } | ||
| 470 | }; | ||
| 471 | const setBrowserAccess = (enabled: boolean | undefined) => | ||
| 472 | setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); | ||
| 473 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | 177 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); |
| 474 | const projectNameEditedRef = useRef(false); | 178 | const projectNameEditedRef = useRef(false); |
| 475 | const mergeMethodOverrides = useClientSettings( | ||
| 476 | (settings) => settings.pullRequestMergeMethodOverrides, | ||
| 477 | ); | ||
| 478 | const projectMergeMethod = mergeMethodOverrides[group.projectKey]; | ||
| 479 | const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { | ||
| 480 | const nextOverrides = { ...mergeMethodOverrides }; | ||
| 481 | if (method === null) { | ||
| 482 | delete nextOverrides[group.projectKey]; | ||
| 483 | } else { | ||
| 484 | nextOverrides[group.projectKey] = method; | ||
| 485 | } | ||
| 486 | updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); | ||
| 487 | }; | ||
| 488 | 179 | ||
| 489 | const faviconPath = representative.faviconPath ?? null; | 180 | const faviconPath = representative.faviconPath ?? null; |
| 490 | const projectIcon = representative.projectIcon ?? null; | 181 | const projectIcon = representative.projectIcon ?? null; |
| ... | @@ -516,14 +207,23 @@ function ProjectDetail({ | ... | @@ -516,14 +207,23 @@ function ProjectDetail({ |
| 516 | async ( | 207 | async ( |
| 517 | input: Partial<{ | 208 | input: Partial<{ |
| 518 | title: string; | 209 | title: string; |
| 519 | defaultModelSelection: ModelSelection | null; | ||
| 520 | defaultThreadEnvMode: ThreadEnvMode | null; | ||
| 521 | autoPull: boolean; | ||
| 522 | faviconPath: string | null; | 210 | faviconPath: string | null; |
| 523 | projectIcon: ProjectIconOverride | null; | 211 | projectIcon: ProjectIconOverride | null; |
| 524 | }>, | 212 | }>, |
| 525 | failureTitle: string, | 213 | failureTitle: string, |
| 526 | ): Promise<AtomCommandResult<void, unknown>> => { | 214 | ): Promise<AtomCommandResult<void, unknown>> => { |
| 215 | const unavailable = group.memberProjects.find((member) => { | ||
| 216 | const environment = environmentById.get(member.environmentId); | ||
| 217 | return environment?.connection.phase !== "connected" || !environment.serverConfig; | ||
| 218 | }); | ||
| 219 | if (unavailable) { | ||
| 220 | const error = new Error( | ||
| 221 | `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`, | ||
| 222 | ); | ||
| 223 | const result: AtomCommandResult<void, unknown> = AsyncResult.failure(Cause.fail(error)); | ||
| 224 | reportFailure(failureTitle, result); | ||
| 225 | return result; | ||
| 226 | } | ||
| 527 | for (const member of group.memberProjects) { | 227 | for (const member of group.memberProjects) { |
| 528 | const result = mapAtomCommandResult( | 228 | const result = mapAtomCommandResult( |
| 529 | await updateProject({ | 229 | await updateProject({ |
| ... | @@ -546,7 +246,7 @@ function ProjectDetail({ | ... | @@ -546,7 +246,7 @@ function ProjectDetail({ |
| 546 | } | 246 | } |
| 547 | return AsyncResult.success(undefined); | 247 | return AsyncResult.success(undefined); |
| 548 | }, | 248 | }, |
| 549 | [group.memberProjects, reportFailure, updateProject], | 249 | [environmentById, group.memberProjects, reportFailure, updateProject], |
| 550 | ); | 250 | ); |
| 551 | 251 | ||
| 552 | const renameGroup = useCallback( | 252 | const renameGroup = useCallback( |
| ... | @@ -570,117 +270,6 @@ function ProjectDetail({ | ... | @@ -570,117 +270,6 @@ function ProjectDetail({ |
| 570 | [group.memberProjects, updateAllMembers], | 270 | [group.memberProjects, updateAllMembers], |
| 571 | ); | 271 | ); |
| 572 | 272 | ||
| 573 | // ----- default model ----- | ||
| 574 | const storedSelection = representative.defaultModelSelection; | ||
| 575 | const resolvedSelection = resolveDefaultProviderModelSelection( | ||
| 576 | serverProviders, | ||
| 577 | storedSelection ?? projectSettings.defaultModelSelection, | ||
| 578 | ); | ||
| 579 | const mixedModel = group.memberProjects.some((member) => { | ||
| 580 | const config = environmentById.get(member.environmentId)?.serverConfig; | ||
| 581 | return ( | ||
| 582 | !Equal.equals(member.defaultModelSelection, storedSelection) || | ||
| 583 | (config !== null && | ||
| 584 | config !== undefined && | ||
| 585 | environmentById.get(representative.environmentId)?.serverConfig != null && | ||
| 586 | JSON.stringify( | ||
| 587 | resolveDefaultProviderModelSelection( | ||
| 588 | config.providers, | ||
| 589 | member.defaultModelSelection ?? config.settings.defaultModelSelection, | ||
| 590 | ), | ||
| 591 | ) !== JSON.stringify(resolvedSelection)) | ||
| 592 | ); | ||
| 593 | }); | ||
| 594 | const resolvedInstanceId = resolvedSelection?.instanceId ?? null; | ||
| 595 | const resolvedModel = resolvedSelection?.model ?? null; | ||
| 596 | const instanceEntries = useMemo( | ||
| 597 | () => | ||
| 598 | sortProviderInstanceEntries( | ||
| 599 | applyProviderInstanceSettings( | ||
| 600 | deriveProviderInstanceEntries(serverProviders), | ||
| 601 | projectSettings, | ||
| 602 | ), | ||
| 603 | ), | ||
| 604 | [serverProviders, projectSettings], | ||
| 605 | ); | ||
| 606 | const modelOptionsByInstance = useMemo( | ||
| 607 | () => | ||
| 608 | getCustomModelOptionsByInstance( | ||
| 609 | projectSettings, | ||
| 610 | serverProviders, | ||
| 611 | resolvedInstanceId, | ||
| 612 | resolvedModel, | ||
| 613 | ), | ||
| 614 | [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], | ||
| 615 | ); | ||
| 616 | const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); | ||
| 617 | const setDefaultModel = (selection: ModelSelection | null) => { | ||
| 618 | if (selection !== null) { | ||
| 619 | for (const member of group.memberProjects) { | ||
| 620 | const environment = environmentById.get(member.environmentId); | ||
| 621 | const config = environment?.serverConfig; | ||
| 622 | const entry = config | ||
| 623 | ? applyProviderInstanceSettings( | ||
| 624 | deriveProviderInstanceEntries(config.providers), | ||
| 625 | config.settings, | ||
| 626 | ).find((candidate) => candidate.instanceId === selection.instanceId) | ||
| 627 | : undefined; | ||
| 628 | const options = config | ||
| 629 | ? getCustomModelOptionsByInstance( | ||
| 630 | { ...projectSettings, ...config.settings }, | ||
| 631 | config.providers, | ||
| 632 | ).get(selection.instanceId) | ||
| 633 | : undefined; | ||
| 634 | if ( | ||
| 635 | !entry?.enabled || | ||
| 636 | !entry.isAvailable || | ||
| 637 | !options?.some((model) => model.slug === selection.model && !model.isUnavailable) | ||
| 638 | ) { | ||
| 639 | toastManager.add({ | ||
| 640 | type: "warning", | ||
| 641 | title: "Project model not saved", | ||
| 642 | description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, | ||
| 643 | }); | ||
| 644 | return; | ||
| 645 | } | ||
| 646 | } | ||
| 647 | } | ||
| 648 | void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); | ||
| 649 | }; | ||
| 650 | |||
| 651 | // ----- new-thread workspace mode ----- | ||
| 652 | const storedEnvMode = representative.defaultThreadEnvMode ?? null; | ||
| 653 | const mixedWorkspace = group.memberProjects.some( | ||
| 654 | (member) => member.defaultThreadEnvMode !== storedEnvMode, | ||
| 655 | ); | ||
| 656 | const setDefaultThreadEnvMode = useCallback( | ||
| 657 | (mode: ThreadEnvMode | null) => | ||
| 658 | void updateAllMembers( | ||
| 659 | { defaultThreadEnvMode: mode }, | ||
| 660 | "Failed to update new-thread workspace", | ||
| 661 | ), | ||
| 662 | [updateAllMembers], | ||
| 663 | ); | ||
| 664 | |||
| 665 | const autoPull = resolveProjectAutoPull( | ||
| 666 | projectSettings, | ||
| 667 | representative.id, | ||
| 668 | representative.autoPull, | ||
| 669 | ); | ||
| 670 | const autoPullOverridden = group.memberProjects.some( | ||
| 671 | (member) => | ||
| 672 | member.autoPull || | ||
| 673 | environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ | ||
| 674 | member.id | ||
| 675 | ] !== undefined, | ||
| 676 | ); | ||
| 677 | const mixedAutoPull = group.memberProjects.some((member) => { | ||
| 678 | const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; | ||
| 679 | return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; | ||
| 680 | }); | ||
| 681 | const setAutoPull = (enabled: boolean | undefined) => | ||
| 682 | setBooleanOverride("projectAutoPullOverrides", enabled); | ||
| 683 | |||
| 684 | // ----- project icon ----- | 273 | // ----- project icon ----- |
| 685 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); | 274 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); |
| 686 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | 275 | const [iconPickerOpen, setIconPickerOpen] = useState(false); |
| ... | @@ -701,99 +290,7 @@ function ProjectDetail({ | ... | @@ -701,99 +290,7 @@ function ProjectDetail({ |
| 701 | [updateAllMembers], | 290 | [updateAllMembers], |
| 702 | ); | 291 | ); |
| 703 | 292 | ||
| 704 | // ----- checkout selection and scripts ----- | ||
| 705 | const hasMultipleCheckouts = group.memberProjects.length > 1; | 293 | const hasMultipleCheckouts = group.memberProjects.length > 1; |
| 706 | const [selectedCheckoutKey, setSelectedCheckoutKey] = useState<string | null>(null); | ||
| 707 | const selectedCheckoutMatch = group.memberProjects.find( | ||
| 708 | (member) => member.physicalProjectKey === selectedCheckoutKey, | ||
| 709 | ); | ||
| 710 | const selectedCheckout = selectedCheckoutMatch ?? representative; | ||
| 711 | const selectedServerConfig = useAtomValue( | ||
| 712 | serverEnvironment.configValueAtom(selectedCheckout.environmentId), | ||
| 713 | ); | ||
| 714 | const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 715 | const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); | ||
| 716 | const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); | ||
| 717 | const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); | ||
| 718 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); | ||
| 719 | const { | ||
| 720 | saving: isSavingScripts, | ||
| 721 | persist: persistScripts, | ||
| 722 | submit: submitScript, | ||
| 723 | } = useProjectScriptSettings([ | ||
| 724 | { | ||
| 725 | environmentId: selectedCheckout.environmentId, | ||
| 726 | settings: scriptSettings, | ||
| 727 | keybindings, | ||
| 728 | project: selectedCheckout, | ||
| 729 | }, | ||
| 730 | ]); | ||
| 731 | const t3File = useT3ProjectFileState( | ||
| 732 | selectedCheckout.environmentId, | ||
| 733 | selectedCheckout.workspaceRoot, | ||
| 734 | ); | ||
| 735 | // What the "Default" option resolves to while no override is set: the | ||
| 736 | // repo's t3.json value when present, otherwise the global setting. | ||
| 737 | const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; | ||
| 738 | const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; | ||
| 739 | const importableScripts = useMemo( | ||
| 740 | () => | ||
| 741 | t3File.scripts.filter( | ||
| 742 | (fileScript) => | ||
| 743 | !scripts.some( | ||
| 744 | (script) => | ||
| 745 | script.command === fileScript.command || | ||
| 746 | script.name.toLowerCase() === fileScript.name.toLowerCase(), | ||
| 747 | ), | ||
| 748 | ), | ||
| 749 | [scripts, t3File.scripts], | ||
| 750 | ); | ||
| 751 | |||
| 752 | const deleteScript = (scriptId: string) => | ||
| 753 | void persistScripts( | ||
| 754 | (current) => current.filter((script) => script.id !== scriptId), | ||
| 755 | scriptId, | ||
| 756 | null, | ||
| 757 | ); | ||
| 758 | |||
| 759 | const importFileScript = useCallback( | ||
| 760 | async (fileScript: T3ProjectFileScript) => { | ||
| 761 | const payload: NewProjectScriptInput = { | ||
| 762 | name: fileScript.name, | ||
| 763 | command: fileScript.command, | ||
| 764 | icon: fileScript.icon ?? "play", | ||
| 765 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, | ||
| 766 | keybinding: null, | ||
| 767 | previewUrl: fileScript.previewUrl ?? null, | ||
| 768 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, | ||
| 769 | }; | ||
| 770 | const result = await submitScript(null, payload); | ||
| 771 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 772 | const error = squashAtomCommandFailure(result); | ||
| 773 | setEditorRequest({ | ||
| 774 | scriptId: null, | ||
| 775 | initial: payload, | ||
| 776 | error: error instanceof Error ? error.message : "Failed to import action.", | ||
| 777 | }); | ||
| 778 | } | ||
| 779 | }, | ||
| 780 | [submitScript, setEditorRequest], | ||
| 781 | ); | ||
| 782 | |||
| 783 | // ----- checkouts ----- | ||
| 784 | const updateGroupingPreference = useCallback( | ||
| 785 | (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { | ||
| 786 | const overrideKey = deriveProjectGroupingOverrideKey(member); | ||
| 787 | const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; | ||
| 788 | if (selection === "inherit") { | ||
| 789 | delete nextOverrides[overrideKey]; | ||
| 790 | } else { | ||
| 791 | nextOverrides[overrideKey] = selection; | ||
| 792 | } | ||
| 793 | updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides }); | ||
| 794 | }, | ||
| 795 | [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings], | ||
| 796 | ); | ||
| 797 | 294 | ||
| 798 | const removeMembers = useCallback( | 295 | const removeMembers = useCallback( |
| 799 | async (members: ReadonlyArray<SidebarProjectGroupMember>) => { | 296 | async (members: ReadonlyArray<SidebarProjectGroupMember>) => { |
| ... | @@ -884,26 +381,32 @@ function ProjectDetail({ | ... | @@ -884,26 +381,32 @@ function ProjectDetail({ |
| 884 | ], | 381 | ], |
| 885 | ); | 382 | ); |
| 886 | 383 | ||
| 887 | const selectedCheckoutGrouping = | 384 | const checkoutChoices = ( |
| 888 | projectGroupingSettings.sidebarProjectGroupingOverrides?.[ | 385 | <SettingsSection title="Checkouts"> |
| 889 | deriveProjectGroupingOverrideKey(selectedCheckout) | 386 | {group.memberProjects.map((member) => ( |
| 890 | ] ?? "inherit"; | 387 | <SettingsRow |
| 891 | const checkoutLabel = (member: SidebarProjectGroupMember) => { | 388 | key={member.physicalProjectKey} |
| 892 | const label = member.environmentLabel ?? "This machine"; | 389 | title={member.environmentLabel ?? "Environment"} |
| 893 | return group.memberProjects.some( | 390 | description={member.workspaceRoot} |
| 894 | (other) => | 391 | control={ |
| 895 | other.physicalProjectKey !== member.physicalProjectKey && | 392 | <Button |
| 896 | (other.environmentLabel ?? "This machine") === label, | 393 | size="sm" |
| 897 | ) | 394 | variant="outline" |
| 898 | ? `${label} · ${member.workspaceRoot}` | 395 | onClick={() => void removeMembers([member])} |
| 899 | : label; | 396 | aria-label={`Remove checkout ${member.workspaceRoot}`} |
| 900 | }; | 397 | > |
| 901 | const selectedCheckoutLabel = checkoutLabel(selectedCheckout); | 398 | Remove |
| 399 | </Button> | ||
| 400 | } | ||
| 401 | /> | ||
| 402 | ))} | ||
| 403 | </SettingsSection> | ||
| 404 | ); | ||
| 902 | 405 | ||
| 903 | return ( | 406 | return ( |
| 904 | <> | 407 | <> |
| 905 | <SettingsPageContainer className="gap-6"> | 408 | <SettingsPageContainer className="gap-6"> |
| 906 | <SettingsSection title="Project" hideTitle> | 409 | <SettingsSection id="project-overview" title="Project" hideTitle> |
| 907 | <SettingsRow | 410 | <SettingsRow |
| 908 | title="Name" | 411 | title="Name" |
| 909 | description="The shared name for this project group in the sidebar and thread lists." | 412 | description="The shared name for this project group in the sidebar and thread lists." |
| ... | @@ -938,7 +441,9 @@ function ProjectDetail({ | ... | @@ -938,7 +441,9 @@ function ProjectDetail({ |
| 938 | : (faviconPath ?? "Automatic") | 441 | : (faviconPath ?? "Automatic") |
| 939 | } | 442 | } |
| 940 | resetAction={ | 443 | resetAction={ |
| 941 | faviconPath !== null || projectIcon !== null ? ( | 444 | group.memberProjects.some( |
| 445 | (member) => member.faviconPath != null || member.projectIcon != null, | ||
| 446 | ) ? ( | ||
| 942 | <SettingResetButton | 447 | <SettingResetButton |
| 943 | label="project icon" | 448 | label="project icon" |
| 944 | disabled={isSavingFavicon} | 449 | disabled={isSavingFavicon} |
| ... | @@ -972,430 +477,9 @@ function ProjectDetail({ | ... | @@ -972,430 +477,9 @@ function ProjectDetail({ |
| 972 | </div> | 477 | </div> |
| 973 | } | 478 | } |
| 974 | /> | 479 | /> |
| 975 | <SettingsRow | ||
| 976 | title="Default merge method" | ||
| 977 | description="Pull requests in this project start with this method. It overrides the last method selected." | ||
| 978 | resetAction={ | ||
| 979 | projectMergeMethod !== undefined ? ( | ||
| 980 | <SettingResetButton | ||
| 981 | label="project merge method" | ||
| 982 | onClick={() => setProjectMergeMethod(null)} | ||
| 983 | /> | ||
| 984 | ) : null | ||
| 985 | } | ||
| 986 | control={ | ||
| 987 | <Select | ||
| 988 | value={projectMergeMethod ?? "inherit"} | ||
| 989 | onValueChange={(value) => | ||
| 990 | setProjectMergeMethod( | ||
| 991 | value === "inherit" ? null : (value as PullRequestMergeMethod), | ||
| 992 | ) | ||
| 993 | } | ||
| 994 | > | ||
| 995 | <SelectTrigger aria-label="Default pull request merge method"> | ||
| 996 | <SelectValue> | ||
| 997 | {projectMergeMethod === undefined | ||
| 998 | ? "Last selected" | ||
| 999 | : PULL_REQUEST_MERGE_METHOD_LABELS[projectMergeMethod]} | ||
| 1000 | </SelectValue> | ||
| 1001 | </SelectTrigger> | ||
| 1002 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 1003 | <SelectItem value="inherit">Last selected</SelectItem> | ||
| 1004 | <SelectItem value="merge">{PULL_REQUEST_MERGE_METHOD_LABELS.merge}</SelectItem> | ||
| 1005 | <SelectItem value="squash">{PULL_REQUEST_MERGE_METHOD_LABELS.squash}</SelectItem> | ||
| 1006 | <SelectItem value="rebase">{PULL_REQUEST_MERGE_METHOD_LABELS.rebase}</SelectItem> | ||
| 1007 | </SelectPopup> | ||
| 1008 | </Select> | ||
| 1009 | } | ||
| 1010 | /> | ||
| 1011 | <SettingsRow | ||
| 1012 | title="Model" | ||
| 1013 | status={ | ||
| 1014 | mixedModel | ||
| 1015 | ? "Mixed defaults or overrides. Choosing a model updates all selected checkouts." | ||
| 1016 | : storedSelection === null | ||
| 1017 | ? "Inherited" | ||
| 1018 | : "Overridden" | ||
| 1019 | } | ||
| 1020 | description={ | ||
| 1021 | storedSelection === null | ||
| 1022 | ? "Inherited from machine defaults. New threads use the default model." | ||
| 1023 | : "Overridden for this project. Reset to use the default model." | ||
| 1024 | } | ||
| 1025 | resetAction={ | ||
| 1026 | group.memberProjects.some((member) => member.defaultModelSelection !== null) ? ( | ||
| 1027 | <SettingResetButton | ||
| 1028 | label="project default model" | ||
| 1029 | tooltip="Reset to inherited model" | ||
| 1030 | onClick={() => setDefaultModel(null)} | ||
| 1031 | /> | ||
| 1032 | ) : null | ||
| 1033 | } | ||
| 1034 | control={ | ||
| 1035 | resolvedSelection && activeEntry ? ( | ||
| 1036 | <div className="flex flex-wrap items-center justify-end gap-1.5"> | ||
| 1037 | <ProviderModelPicker | ||
| 1038 | activeInstanceId={resolvedSelection.instanceId} | ||
| 1039 | model={resolvedSelection.model} | ||
| 1040 | lockedProvider={null} | ||
| 1041 | instanceEntries={instanceEntries} | ||
| 1042 | modelOptionsByInstance={modelOptionsByInstance} | ||
| 1043 | triggerVariant="outline" | ||
| 1044 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | ||
| 1045 | onOpenProviderSetup={(instanceId) => { | ||
| 1046 | void navigate({ | ||
| 1047 | to: "/settings/providers", | ||
| 1048 | search: { environmentId: representative.environmentId, instanceId }, | ||
| 1049 | }); | ||
| 1050 | }} | ||
| 1051 | onInstanceModelChange={(instanceId, model) => { | ||
| 1052 | setDefaultModel(createModelSelection(instanceId, model)); | ||
| 1053 | }} | ||
| 1054 | /> | ||
| 1055 | <TraitsPicker | ||
| 1056 | provider={activeEntry.driverKind as ProviderDriverKind} | ||
| 1057 | models={activeEntry.models} | ||
| 1058 | model={resolvedSelection.model} | ||
| 1059 | prompt="" | ||
| 1060 | onPromptChange={() => {}} | ||
| 1061 | modelOptions={resolvedSelection.options ?? []} | ||
| 1062 | allowPromptInjectedEffort={false} | ||
| 1063 | planModeEnabled={projectSettings.planModeEnabled} | ||
| 1064 | triggerVariant="outline" | ||
| 1065 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | ||
| 1066 | onModelOptionsChange={(nextOptions) => { | ||
| 1067 | setDefaultModel( | ||
| 1068 | createModelSelection( | ||
| 1069 | resolvedSelection.instanceId, | ||
| 1070 | resolvedSelection.model, | ||
| 1071 | nextOptions, | ||
| 1072 | ), | ||
| 1073 | ); | ||
| 1074 | }} | ||
| 1075 | /> | ||
| 1076 | </div> | ||
| 1077 | ) : ( | ||
| 1078 | <span className="text-sm text-muted-foreground">No providers available</span> | ||
| 1079 | ) | ||
| 1080 | } | ||
| 1081 | /> | ||
| 1082 | <SettingsRow | ||
| 1083 | title="Workspace" | ||
| 1084 | status={ | ||
| 1085 | mixedWorkspace | ||
| 1086 | ? "Mixed overrides. Choosing a workspace updates all selected checkouts." | ||
| 1087 | : storedEnvMode === null | ||
| 1088 | ? "Inherited" | ||
| 1089 | : "Overridden" | ||
| 1090 | } | ||
| 1091 | description={ | ||
| 1092 | storedEnvMode === null | ||
| 1093 | ? "Inherited from t3.json or machine defaults." | ||
| 1094 | : "Overridden for this project. Reset to inherit its workspace default." | ||
| 1095 | } | ||
| 1096 | resetAction={ | ||
| 1097 | group.memberProjects.some((member) => member.defaultThreadEnvMode !== null) ? ( | ||
| 1098 | <SettingResetButton | ||
| 1099 | label="project workspace default" | ||
| 1100 | tooltip="Reset to inherited workspace" | ||
| 1101 | onClick={() => setDefaultThreadEnvMode(null)} | ||
| 1102 | /> | ||
| 1103 | ) : null | ||
| 1104 | } | ||
| 1105 | control={ | ||
| 1106 | <Select | ||
| 1107 | value={storedEnvMode ?? "inherit"} | ||
| 1108 | onValueChange={(value) => { | ||
| 1109 | if (value === "worktree" || value === "local") { | ||
| 1110 | setDefaultThreadEnvMode(value); | ||
| 1111 | } else if (value === "inherit") { | ||
| 1112 | setDefaultThreadEnvMode(null); | ||
| 1113 | } | ||
| 1114 | }} | ||
| 1115 | > | ||
| 1116 | <SelectTrigger size="sm" aria-label="New-thread workspace"> | ||
| 1117 | <SelectValue> | ||
| 1118 | {storedEnvMode === null | ||
| 1119 | ? group.memberProjects.length > 1 | ||
| 1120 | ? "Default (per checkout)" | ||
| 1121 | : `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})` | ||
| 1122 | : resolveEnvModeLabel(storedEnvMode)} | ||
| 1123 | </SelectValue> | ||
| 1124 | </SelectTrigger> | ||
| 1125 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 1126 | <SelectItem value="inherit"> | ||
| 1127 | {group.memberProjects.length > 1 | ||
| 1128 | ? "Default (each checkout's t3.json or global setting)" | ||
| 1129 | : `Default (${inheritedEnvModeSource}: ${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`} | ||
| 1130 | </SelectItem> | ||
| 1131 | <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem> | ||
| 1132 | <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem> | ||
| 1133 | </SelectPopup> | ||
| 1134 | </Select> | ||
| 1135 | } | ||
| 1136 | /> | ||
| 1137 | <SettingsRow | ||
| 1138 | title="Automatically pull" | ||
| 1139 | description="Keeps the default branch current in the background when the checkout has no local changes or commits." | ||
| 1140 | status={ | ||
| 1141 | mixedAutoPull | ||
| 1142 | ? "Mixed" | ||
| 1143 | : autoPullOverridden | ||
| 1144 | ? "Overridden" | ||
| 1145 | : `Inherited (${autoPull ? "on" : "off"})` | ||
| 1146 | } | ||
| 1147 | resetAction={ | ||
| 1148 | autoPullOverridden ? ( | ||
| 1149 | <SettingResetButton | ||
| 1150 | label="automatic pull" | ||
| 1151 | tooltip="Reset to inherited automatic pull setting" | ||
| 1152 | disabled={savingBrowserAccess} | ||
| 1153 | onClick={() => void setAutoPull(undefined)} | ||
| 1154 | /> | ||
| 1155 | ) : null | ||
| 1156 | } | ||
| 1157 | control={ | ||
| 1158 | <Switch | ||
| 1159 | checked={autoPull} | ||
| 1160 | disabled={savingBrowserAccess} | ||
| 1161 | aria-label="Automatically pull the default branch" | ||
| 1162 | onCheckedChange={(enabled) => void setAutoPull(enabled)} | ||
| 1163 | /> | ||
| 1164 | } | ||
| 1165 | /> | ||
| 1166 | <SettingsRow | ||
| 1167 | title="Agent browser access" | ||
| 1168 | description={ | ||
| 1169 | browserMixed | ||
| 1170 | ? "Mixed defaults or overrides across selected checkouts." | ||
| 1171 | : browserOverride === undefined | ||
| 1172 | ? "Inherited from machine defaults. Controls agent access to the preview browser." | ||
| 1173 | : "Overridden for this project. Applies when the agent session next starts." | ||
| 1174 | } | ||
| 1175 | resetAction={ | ||
| 1176 | browserOverrides.some((value) => value !== undefined) ? ( | ||
| 1177 | <SettingResetButton | ||
| 1178 | label="project browser access" | ||
| 1179 | tooltip="Reset to inherited browser access" | ||
| 1180 | disabled={savingBrowserAccess} | ||
| 1181 | onClick={() => void setBrowserAccess(undefined)} | ||
| 1182 | /> | ||
| 1183 | ) : null | ||
| 1184 | } | ||
| 1185 | control={ | ||
| 1186 | <Select | ||
| 1187 | value={ | ||
| 1188 | browserMixed | ||
| 1189 | ? "mixed" | ||
| 1190 | : browserOverride === undefined | ||
| 1191 | ? "inherit" | ||
| 1192 | : browserOverride | ||
| 1193 | ? "enabled" | ||
| 1194 | : "disabled" | ||
| 1195 | } | ||
| 1196 | disabled={savingBrowserAccess} | ||
| 1197 | onValueChange={(value) => { | ||
| 1198 | if (value === "inherit") void setBrowserAccess(undefined); | ||
| 1199 | else if (value === "enabled" || value === "disabled") | ||
| 1200 | void setBrowserAccess(value === "enabled"); | ||
| 1201 | }} | ||
| 1202 | > | ||
| 1203 | <SelectTrigger size="sm" aria-label="Project agent browser access"> | ||
| 1204 | <SelectValue> | ||
| 1205 | {browserMixed | ||
| 1206 | ? "Mixed" | ||
| 1207 | : browserOverride === undefined | ||
| 1208 | ? `Inherit (${projectSettings.enableAgentBrowserAccess ? "on" : "off"})` | ||
| 1209 | : browserOverride | ||
| 1210 | ? "On" | ||
| 1211 | : "Off"} | ||
| 1212 | </SelectValue> | ||
| 1213 | </SelectTrigger> | ||
| 1214 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 1215 | <SelectItem value="inherit">Inherit defaults</SelectItem> | ||
| 1216 | <SelectItem value="enabled">On</SelectItem> | ||
| 1217 | <SelectItem value="disabled">Off</SelectItem> | ||
| 1218 | </SelectPopup> | ||
| 1219 | </Select> | ||
| 1220 | } | ||
| 1221 | /> | ||
| 1222 | </SettingsSection> | 480 | </SettingsSection> |
| 1223 | 481 | <ProjectActionsSettings /> | |
| 1224 | <SettingsSection title="Checkout"> | 482 | {hasMultipleCheckouts ? checkoutChoices : null} |
| 1225 | {hasMultipleCheckouts ? ( | ||
| 1226 | <SettingsRow | ||
| 1227 | title="Checkout" | ||
| 1228 | description="Actions and grouping belong to this checkout." | ||
| 1229 | control={ | ||
| 1230 | <Select | ||
| 1231 | value={selectedCheckout.physicalProjectKey} | ||
| 1232 | onValueChange={(value) => { | ||
| 1233 | if (value) setSelectedCheckoutKey(value); | ||
| 1234 | }} | ||
| 1235 | > | ||
| 1236 | <SelectTrigger size="sm" aria-label="Checkout"> | ||
| 1237 | <SelectValue className="max-w-96 truncate">{selectedCheckoutLabel}</SelectValue> | ||
| 1238 | </SelectTrigger> | ||
| 1239 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 1240 | {group.memberProjects.map((member) => ( | ||
| 1241 | <SelectItem key={member.physicalProjectKey} value={member.physicalProjectKey}> | ||
| 1242 | <span className="max-w-96 whitespace-normal break-all"> | ||
| 1243 | {checkoutLabel(member)} | ||
| 1244 | </span> | ||
| 1245 | </SelectItem> | ||
| 1246 | ))} | ||
| 1247 | </SelectPopup> | ||
| 1248 | </Select> | ||
| 1249 | } | ||
| 1250 | /> | ||
| 1251 | ) : null} | ||
| 1252 | <SettingsRow | ||
| 1253 | title="Project grouping" | ||
| 1254 | description="How this checkout joins project groups in the sidebar. Changing it can move you to a different project group." | ||
| 1255 | resetAction={ | ||
| 1256 | selectedCheckoutGrouping !== "inherit" ? ( | ||
| 1257 | <SettingResetButton | ||
| 1258 | label="project grouping" | ||
| 1259 | tooltip="Reset to inherited project grouping" | ||
| 1260 | onClick={() => updateGroupingPreference(selectedCheckout, "inherit")} | ||
| 1261 | /> | ||
| 1262 | ) : null | ||
| 1263 | } | ||
| 1264 | control={ | ||
| 1265 | <Select | ||
| 1266 | value={selectedCheckoutGrouping} | ||
| 1267 | onValueChange={(value) => { | ||
| 1268 | if ( | ||
| 1269 | value === "inherit" || | ||
| 1270 | value === "repository" || | ||
| 1271 | value === "repository_path" || | ||
| 1272 | value === "separate" | ||
| 1273 | ) { | ||
| 1274 | updateGroupingPreference(selectedCheckout, value); | ||
| 1275 | } | ||
| 1276 | }} | ||
| 1277 | > | ||
| 1278 | <SelectTrigger size="sm" aria-label={`Grouping rule for ${selectedCheckoutLabel}`}> | ||
| 1279 | <SelectValue> | ||
| 1280 | {selectedCheckoutGrouping === "inherit" | ||
| 1281 | ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` | ||
| 1282 | : PROJECT_GROUPING_MODE_LABELS[selectedCheckoutGrouping]} | ||
| 1283 | </SelectValue> | ||
| 1284 | </SelectTrigger> | ||
| 1285 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 1286 | <SelectItem hideIndicator value="inherit"> | ||
| 1287 | Use global default | ||
| 1288 | </SelectItem> | ||
| 1289 | <SelectItem hideIndicator value="repository"> | ||
| 1290 | {PROJECT_GROUPING_MODE_LABELS.repository} | ||
| 1291 | </SelectItem> | ||
| 1292 | <SelectItem hideIndicator value="repository_path"> | ||
| 1293 | {PROJECT_GROUPING_MODE_LABELS.repository_path} | ||
| 1294 | </SelectItem> | ||
| 1295 | <SelectItem hideIndicator value="separate"> | ||
| 1296 | {PROJECT_GROUPING_MODE_LABELS.separate} | ||
| 1297 | </SelectItem> | ||
| 1298 | </SelectPopup> | ||
| 1299 | </Select> | ||
| 1300 | } | ||
| 1301 | /> | ||
| 1302 | {group.memberProjects.length > 1 ? ( | ||
| 1303 | <SettingsRow | ||
| 1304 | title="Remove checkout" | ||
| 1305 | description="Removes this checkout and its threads from the project group. Files on disk are not touched." | ||
| 1306 | control={ | ||
| 1307 | <Button | ||
| 1308 | size="sm" | ||
| 1309 | variant="destructive-outline" | ||
| 1310 | onClick={() => void removeMembers([selectedCheckout])} | ||
| 1311 | > | ||
| 1312 | <Trash2Icon className="size-3.5" /> | ||
| 1313 | Remove checkout | ||
| 1314 | </Button> | ||
| 1315 | } | ||
| 1316 | /> | ||
| 1317 | ) : null} | ||
| 1318 | <div className="flex min-h-8 flex-col items-start gap-3 px-3 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-4"> | ||
| 1319 | <div className="min-w-0"> | ||
| 1320 | <h3 className="text-base font-semibold text-foreground">Actions</h3> | ||
| 1321 | <p className="text-pretty text-sm text-muted-foreground"> | ||
| 1322 | {scriptsInherited | ||
| 1323 | ? "Inherited from machine defaults." | ||
| 1324 | : `Overridden for ${selectedCheckoutLabel}.`} | ||
| 1325 | </p> | ||
| 1326 | </div> | ||
| 1327 | <div className="flex w-full flex-wrap gap-1.5 sm:w-auto sm:shrink-0 sm:justify-end"> | ||
| 1328 | {!scriptsInherited ? ( | ||
| 1329 | <SettingResetButton | ||
| 1330 | label="project actions" | ||
| 1331 | tooltip="Reset to inherited actions" | ||
| 1332 | disabled={isSavingScripts} | ||
| 1333 | onClick={() => void persistScripts(() => null)} | ||
| 1334 | /> | ||
| 1335 | ) : null} | ||
| 1336 | {importableScripts.length > 0 ? ( | ||
| 1337 | <Menu> | ||
| 1338 | <MenuTrigger | ||
| 1339 | render={ | ||
| 1340 | <Button size="xs" variant="ghost" disabled={isSavingScripts} type="button" /> | ||
| 1341 | } | ||
| 1342 | > | ||
| 1343 | Import scripts | ||
| 1344 | <ChevronDownIcon className="size-3.5" /> | ||
| 1345 | </MenuTrigger> | ||
| 1346 | <MenuPopup align="end" className="w-72"> | ||
| 1347 | <MenuGroup> | ||
| 1348 | <MenuGroupLabel>Import from t3.json</MenuGroupLabel> | ||
| 1349 | <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground"> | ||
| 1350 | Add actions declared by this checkout without editing them first. | ||
| 1351 | </p> | ||
| 1352 | </MenuGroup> | ||
| 1353 | <MenuSeparator /> | ||
| 1354 | {importableScripts.map((fileScript) => ( | ||
| 1355 | <MenuItem | ||
| 1356 | key={`${fileScript.name} ${fileScript.command}`} | ||
| 1357 | onClick={() => void importFileScript(fileScript)} | ||
| 1358 | > | ||
| 1359 | <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" /> | ||
| 1360 | <div className="min-w-0 flex-1"> | ||
| 1361 | <div className="truncate font-medium">{fileScript.name}</div> | ||
| 1362 | <div className="truncate font-mono text-muted-foreground"> | ||
| 1363 | {fileScript.command} | ||
| 1364 | </div> | ||
| 1365 | </div> | ||
| 1366 | </MenuItem> | ||
| 1367 | ))} | ||
| 1368 | </MenuPopup> | ||
| 1369 | </Menu> | ||
| 1370 | ) : null} | ||
| 1371 | <Button | ||
| 1372 | size="xs" | ||
| 1373 | variant="outline" | ||
| 1374 | disabled={isSavingScripts} | ||
| 1375 | onClick={() => | ||
| 1376 | setEditorRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT }) | ||
| 1377 | } | ||
| 1378 | > | ||
| 1379 | <PlusIcon className="size-3.5" /> | ||
| 1380 | Add action | ||
| 1381 | </Button> | ||
| 1382 | </div> | ||
| 1383 | </div> | ||
| 1384 | <ProjectActionsList | ||
| 1385 | scripts={scripts} | ||
| 1386 | keybindings={keybindings} | ||
| 1387 | disabled={isSavingScripts} | ||
| 1388 | onEdit={(script) => setEditorRequest(editorRequestForScript(script, keybindings))} | ||
| 1389 | /> | ||
| 1390 | {t3File.status === "invalid" ? ( | ||
| 1391 | <SettingsRow | ||
| 1392 | title="t3.json is invalid" | ||
| 1393 | description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values." | ||
| 1394 | className="text-warning" | ||
| 1395 | /> | ||
| 1396 | ) : null} | ||
| 1397 | </SettingsSection> | ||
| 1398 | |||
| 1399 | <SettingsSection title="Danger"> | 483 | <SettingsSection title="Danger"> |
| 1400 | <SettingsRow | 484 | <SettingsRow |
| 1401 | title={ | 485 | title={ |
| ... | @@ -1430,13 +514,6 @@ function ProjectDetail({ | ... | @@ -1430,13 +514,6 @@ function ProjectDetail({ |
| 1430 | </SettingsSection> | 514 | </SettingsSection> |
| 1431 | </SettingsPageContainer> | 515 | </SettingsPageContainer> |
| 1432 | 516 | ||
| 1433 | <ProjectScriptEditorDialog | ||
| 1434 | request={editorRequest} | ||
| 1435 | scripts={scripts} | ||
| 1436 | onSubmit={submitScript} | ||
| 1437 | onDelete={deleteScript} | ||
| 1438 | onClose={() => setEditorRequest(null)} | ||
| 1439 | /> | ||
| 1440 | <ProjectFaviconPickerDialog | 517 | <ProjectFaviconPickerDialog |
| 1441 | key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`} | 518 | key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`} |
| 1442 | cwd={representative.workspaceRoot} | 519 | cwd={representative.workspaceRoot} |
apps/web/src/components/settings/ProjectsSettings.tsx+10-30| ... | @@ -1,21 +1,12 @@ | ... | @@ -1,21 +1,12 @@ |
| 1 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { useEnvironments } from "../../state/environments"; | 2 | |
| 3 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; | 3 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; |
| 4 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | 4 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 5 | import { SettingsScopeSelects } from "./SettingsScopeSelects"; | 5 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 6 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | ||
| 7 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | ||
| 8 | 6 | ||
| 9 | export function ProjectsSettings({ | 7 | /** Project identity and checkout management for the selected project. */ |
| 10 | value, | 8 | export function ProjectsSettings() { |
| 11 | onScopeChange, | 9 | const { search: value, scope } = useSettingsScope(); |
| 12 | }: { | ||
| 13 | value: SettingsScopeSearch; | ||
| 14 | onScopeChange: (scope: SettingsScopeSearch) => void; | ||
| 15 | }) { | ||
| 16 | const groups = useSettingsProjectGroups(); | ||
| 17 | const { environments } = useEnvironments(); | ||
| 18 | const scope = resolveSettingsScope(value, groups, environments); | ||
| 19 | // The panel follows remembered members when grouping replaces a project key. | 10 | // The panel follows remembered members when grouping replaces a project key. |
| 20 | const projectScope = | 11 | const projectScope = |
| 21 | scope.kind === "project" || | 12 | scope.kind === "project" || |
| ... | @@ -24,16 +15,6 @@ export function ProjectsSettings({ | ... | @@ -24,16 +15,6 @@ export function ProjectsSettings({ |
| 24 | (scope.reason === "project-missing" || scope.reason === "checkout-missing")); | 15 | (scope.reason === "project-missing" || scope.reason === "checkout-missing")); |
| 25 | return ( | 16 | return ( |
| 26 | <div className="flex min-h-0 flex-1 flex-col"> | 17 | <div className="flex min-h-0 flex-1 flex-col"> |
| 27 | <div className="scrollbar-gutter-both shrink-0 overflow-y-auto"> | ||
| 28 | <WorkspacePageContainer className="items-end pb-0"> | ||
| 29 | <SettingsScopeSelects | ||
| 30 | value={value} | ||
| 31 | groups={groups} | ||
| 32 | environments={environments} | ||
| 33 | onChange={onScopeChange} | ||
| 34 | /> | ||
| 35 | </WorkspacePageContainer> | ||
| 36 | </div> | ||
| 37 | {value.project && projectScope ? ( | 18 | {value.project && projectScope ? ( |
| 38 | <ProjectSettingsPanel | 19 | <ProjectSettingsPanel |
| 39 | projectKey={value.project} | 20 | projectKey={value.project} |
| ... | @@ -43,11 +24,10 @@ export function ProjectsSettings({ | ... | @@ -43,11 +24,10 @@ export function ProjectsSettings({ |
| 43 | ) : scope.kind === "unavailable" ? ( | 24 | ) : scope.kind === "unavailable" ? ( |
| 44 | <p className="p-8 text-sm text-muted-foreground">{scope.message}</p> | 25 | <p className="p-8 text-sm text-muted-foreground">{scope.message}</p> |
| 45 | ) : ( | 26 | ) : ( |
| 46 | <ProjectDefaultsSettings | 27 | <SettingsScopeNotice target="project"> |
| 47 | environmentId={scope.kind === "environment" ? scope.environmentId : null} | 28 | Choose a project to manage its name, icon, checkouts and actions. |
| 48 | /> | 29 | </SettingsScopeNotice> |
| 49 | )} | 30 | )} |
| 50 | </div> | 31 | </div> |
| 51 | ); | 32 | ); |
| 52 | } | 33 | } |
| 53 | import { EnvironmentId } from "@t3tools/contracts"; |
apps/web/src/components/settings/ProviderInstanceCard.tsx+4| ... | @@ -896,6 +896,10 @@ export function ProviderInstanceCard({ | ... | @@ -896,6 +896,10 @@ export function ProviderInstanceCard({ |
| 896 | className={readOnly ? "opacity-50 select-none" : undefined} | 896 | className={readOnly ? "opacity-50 select-none" : undefined} |
| 897 | > | 897 | > |
| 898 | <div className="px-3 py-3 sm:px-4"> | 898 | <div className="px-3 py-3 sm:px-4"> |
| 899 | <p className="mb-3 text-xs text-muted-foreground"> | ||
| 900 | Favorites, visibility, and ordering are saved on this device. Custom models are saved | ||
| 901 | on the selected environment. | ||
| 902 | </p> | ||
| 899 | <ProviderModelsSection | 903 | <ProviderModelsSection |
| 900 | instanceId={instanceId} | 904 | instanceId={instanceId} |
| 901 | driverKind={driverKind} | 905 | driverKind={driverKind} |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+28-5| ... | @@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({ | ... | @@ -29,6 +29,7 @@ const settingsState = vi.hoisted(() => ({ |
| 29 | readEnvironmentIds: [] as EnvironmentId[], | 29 | readEnvironmentIds: [] as EnvironmentId[], |
| 30 | updateEnvironmentIds: [] as EnvironmentId[], | 30 | updateEnvironmentIds: [] as EnvironmentId[], |
| 31 | updateSettings: vi.fn(), | 31 | updateSettings: vi.fn(), |
| 32 | updateClientSettings: vi.fn(), | ||
| 32 | })); | 33 | })); |
| 33 | 34 | ||
| 34 | const settingsSearchState = vi.hoisted(() => ({ | 35 | const settingsSearchState = vi.hoisted(() => ({ |
| ... | @@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({ | ... | @@ -81,14 +82,12 @@ vi.mock("../../state/use-atom-command", () => ({ |
| 81 | })); | 82 | })); |
| 82 | 83 | ||
| 83 | vi.mock("../../hooks/useSettings", () => ({ | 84 | vi.mock("../../hooks/useSettings", () => ({ |
| 85 | useUpdateClientSettings: () => settingsState.updateClientSettings, | ||
| 84 | useEnvironmentSettings: (environmentId: EnvironmentId) => { | 86 | useEnvironmentSettings: (environmentId: EnvironmentId) => { |
| 85 | settingsState.readEnvironmentIds.push(environmentId); | 87 | settingsState.readEnvironmentIds.push(environmentId); |
| 86 | return settingsState.value; | 88 | return settingsState.value; |
| 87 | }, | 89 | }, |
| 88 | useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { | 90 | useUpdateEnvironmentSettings: () => settingsState.updateSettings, |
| 89 | settingsState.updateEnvironmentIds.push(environmentId); | ||
| 90 | return settingsState.updateSettings; | ||
| 91 | }, | ||
| 92 | })); | 91 | })); |
| 93 | 92 | ||
| 94 | vi.mock("../../environments/primary", () => ({ | 93 | vi.mock("../../environments/primary", () => ({ |
| ... | @@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -177,6 +176,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 177 | settingsState.readEnvironmentIds = []; | 176 | settingsState.readEnvironmentIds = []; |
| 178 | settingsState.updateEnvironmentIds = []; | 177 | settingsState.updateEnvironmentIds = []; |
| 179 | settingsState.updateSettings.mockReset(); | 178 | settingsState.updateSettings.mockReset(); |
| 179 | settingsState.updateClientSettings.mockReset(); | ||
| 180 | settingsSearchState.targetId = null; | 180 | settingsSearchState.targetId = null; |
| 181 | settingsSearchState.effects = []; | 181 | settingsSearchState.effects = []; |
| 182 | commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); | 182 | commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); |
| ... | @@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -186,7 +186,6 @@ describe("EnvironmentProviderSettings routing", () => { |
| 186 | it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { | 186 | it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { |
| 187 | expect(() => renderPanel()).not.toThrow(); | 187 | expect(() => renderPanel()).not.toThrow(); |
| 188 | expect(settingsState.readEnvironmentIds).toEqual([environmentId]); | 188 | expect(settingsState.readEnvironmentIds).toEqual([environmentId]); |
| 189 | expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); | ||
| 190 | }); | 189 | }); |
| 191 | 190 | ||
| 192 | it("routes refresh and provider update commands to the selected environment", async () => { | 191 | it("routes refresh and provider update commands to the selected environment", async () => { |
| ... | @@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -230,6 +229,30 @@ describe("EnvironmentProviderSettings routing", () => { |
| 230 | expect(editor?.props.instanceId).toBe(customId); | 229 | expect(editor?.props.instanceId).toBe(customId); |
| 231 | }); | 230 | }); |
| 232 | 231 | ||
| 232 | it.each([ | ||
| 233 | ["onFavoriteModelsChange", { favorites: [{ provider: codexId, model: "chosen" }] }], | ||
| 234 | [ | ||
| 235 | "onHiddenModelsChange", | ||
| 236 | { providerModelPreferences: { [codexId]: { hiddenModels: ["chosen"], modelOrder: [] } } }, | ||
| 237 | ], | ||
| 238 | [ | ||
| 239 | "onModelOrderChange", | ||
| 240 | { providerModelPreferences: { [codexId]: { hiddenModels: [], modelOrder: ["chosen"] } } }, | ||
| 241 | ], | ||
| 242 | ])("saves %s on this device without changing the selected server", (action, expected) => { | ||
| 243 | atoms.providers = [provider()]; | ||
| 244 | const panel = renderPanel(); | ||
| 245 | const editor = visitElements( | ||
| 246 | panel, | ||
| 247 | (element) => element.props.instanceId === codexId && element.props.mode === "editor", | ||
| 248 | ); | ||
| 249 | expect(editor).not.toBeNull(); | ||
| 250 | if (!editor) throw new Error("Provider editor was not rendered"); | ||
| 251 | (editor.props[action] as (models: string[]) => void)(["chosen"]); | ||
| 252 | expect(settingsState.updateClientSettings).toHaveBeenCalledExactlyOnceWith(expected); | ||
| 253 | expect(settingsState.updateSettings).not.toHaveBeenCalled(); | ||
| 254 | }); | ||
| 255 | |||
| 233 | it("does not substitute another account when the requested instance was removed", () => { | 256 | it("does not substitute another account when the requested instance was removed", () => { |
| 234 | atoms.providers = [provider()]; | 257 | atoms.providers = [provider()]; |
| 235 | const panel = renderPanel({ targetInstanceId: customId }); | 258 | const panel = renderPanel({ targetInstanceId: customId }); |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+23-8| ... | @@ -31,7 +31,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro | ... | @@ -31,7 +31,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro |
| 31 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; | 31 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| 32 | import { isElectron } from "../../env"; | 32 | import { isElectron } from "../../env"; |
| 33 | import { usePrimarySessionState } from "../../environments/primary"; | 33 | import { usePrimarySessionState } from "../../environments/primary"; |
| 34 | import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | 34 | import { |
| 35 | useEnvironmentSettings, | ||
| 36 | useUpdateClientSettings, | ||
| 37 | useUpdateEnvironmentSettings, | ||
| 38 | } from "../../hooks/useSettings"; | ||
| 35 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | 39 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 36 | import { cn } from "../../lib/utils"; | 40 | import { cn } from "../../lib/utils"; |
| 37 | import { resolveAppModelSelectionState } from "../../modelSelection"; | 41 | import { resolveAppModelSelectionState } from "../../modelSelection"; |
| ... | @@ -262,6 +266,7 @@ function EnvironmentUnavailablePlaceholder({ | ... | @@ -262,6 +266,7 @@ function EnvironmentUnavailablePlaceholder({ |
| 262 | interface ProviderSettingsTarget { | 266 | interface ProviderSettingsTarget { |
| 263 | readonly environmentId?: EnvironmentId; | 267 | readonly environmentId?: EnvironmentId; |
| 264 | readonly instanceId?: ProviderInstanceId; | 268 | readonly instanceId?: ProviderInstanceId; |
| 269 | readonly scoped?: boolean; | ||
| 265 | } | 270 | } |
| 266 | 271 | ||
| 267 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { | 272 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { |
| ... | @@ -293,9 +298,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -293,9 +298,10 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 293 | target.environmentId !== undefined && | 298 | target.environmentId !== undefined && |
| 294 | selectedEnvironmentId === target.environmentId && | 299 | selectedEnvironmentId === target.environmentId && |
| 295 | !options.some((environment) => environment.environmentId === target.environmentId); | 300 | !options.some((environment) => environment.environmentId === target.environmentId); |
| 296 | const effectiveEnvironmentId = targetEnvironmentMissing | 301 | const effectiveEnvironmentId = |
| 297 | ? target.environmentId | 302 | target.scoped || targetEnvironmentMissing |
| 298 | : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId); | 303 | ? target.environmentId |
| 304 | : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId); | ||
| 299 | const selectedEnvironment = | 305 | const selectedEnvironment = |
| 300 | options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; | 306 | options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; |
| 301 | const selectedEnvironmentCanRenderSettings = | 307 | const selectedEnvironmentCanRenderSettings = |
| ... | @@ -312,6 +318,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -312,6 +318,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 312 | )?.environmentId; | 318 | )?.environmentId; |
| 313 | useEffect(() => { | 319 | useEffect(() => { |
| 314 | if ( | 320 | if ( |
| 321 | !target.scoped && | ||
| 315 | (searchTargetId === searchableSetting("provider-health-check-interval").id || | 322 | (searchTargetId === searchableSetting("provider-health-check-interval").id || |
| 316 | searchTargetId === searchableSetting("usage-providers").id) && | 323 | searchTargetId === searchableSetting("usage-providers").id) && |
| 317 | !selectedEnvironmentCanRenderSettings && | 324 | !selectedEnvironmentCanRenderSettings && |
| ... | @@ -319,11 +326,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -319,11 +326,16 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 319 | ) { | 326 | ) { |
| 320 | setSelectedEnvironmentId(searchableEnvironmentId); | 327 | setSelectedEnvironmentId(searchableEnvironmentId); |
| 321 | } | 328 | } |
| 322 | }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); | 329 | }, [ |
| 330 | searchTargetId, | ||
| 331 | searchableEnvironmentId, | ||
| 332 | selectedEnvironmentCanRenderSettings, | ||
| 333 | target.scoped, | ||
| 334 | ]); | ||
| 323 | const onlyPrimaryDevice = | 335 | const onlyPrimaryDevice = |
| 324 | options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; | 336 | options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; |
| 325 | const deviceTabs = | 337 | const deviceTabs = |
| 326 | !onlyPrimaryDevice && options.length > 0 ? ( | 338 | !target.scoped && !onlyPrimaryDevice && options.length > 0 ? ( |
| 327 | <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 flex-1 rounded-none"> | 339 | <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 flex-1 rounded-none"> |
| 328 | <ToggleGroup | 340 | <ToggleGroup |
| 329 | aria-label="Devices" | 341 | aria-label="Devices" |
| ... | @@ -559,7 +571,10 @@ export function EnvironmentProviderSettings({ | ... | @@ -559,7 +571,10 @@ export function EnvironmentProviderSettings({ |
| 559 | readonly readOnly?: boolean; | 571 | readonly readOnly?: boolean; |
| 560 | }) { | 572 | }) { |
| 561 | const settings = useEnvironmentSettings(environmentId); | 573 | const settings = useEnvironmentSettings(environmentId); |
| 574 | // Provider instances hold per-machine credentials and binaries, so this | ||
| 575 | // page always edits exactly the environment it displays. | ||
| 562 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | 576 | const updateSettings = useUpdateEnvironmentSettings(environmentId); |
| 577 | const updateClientSettings = useUpdateClientSettings(); | ||
| 563 | const serverProviders = | 578 | const serverProviders = |
| 564 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; | 579 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; |
| 565 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { | 580 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| ... | @@ -815,7 +830,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -815,7 +830,7 @@ export function EnvironmentProviderSettings({ |
| 815 | const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | 830 | const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; |
| 816 | const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | 831 | const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; |
| 817 | const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | 832 | const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); |
| 818 | updateSettings({ | 833 | updateClientSettings({ |
| 819 | providerModelPreferences: | 834 | providerModelPreferences: |
| 820 | hiddenModels.length === 0 && modelOrder.length === 0 | 835 | hiddenModels.length === 0 && modelOrder.length === 0 |
| 821 | ? rest | 836 | ? rest |
| ... | @@ -841,7 +856,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -841,7 +856,7 @@ export function EnvironmentProviderSettings({ |
| 841 | }), | 856 | }), |
| 842 | ), | 857 | ), |
| 843 | ]; | 858 | ]; |
| 844 | updateSettings({ | 859 | updateClientSettings({ |
| 845 | favorites: [ | 860 | favorites: [ |
| 846 | ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | 861 | ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), |
| 847 | ...favoriteModels.map((model) => ({ provider: instanceId, model })), | 862 | ...favoriteModels.map((model) => ({ provider: instanceId, model })), |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+26-15| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | } from "lucide-react"; | 13 | } from "lucide-react"; |
| 14 | import type { | 14 | import type { |
| 15 | BackgroundBooleanState, | 15 | BackgroundBooleanState, |
| 16 | EnvironmentId, | ||
| 16 | ResourceAttributionEntry, | 17 | ResourceAttributionEntry, |
| 17 | ResourceTelemetryAggregate, | 18 | ResourceTelemetryAggregate, |
| 18 | ResourceTelemetryHistoryBucket, | 19 | ResourceTelemetryHistoryBucket, |
| ... | @@ -26,7 +27,7 @@ import type { | ... | @@ -26,7 +27,7 @@ import type { |
| 26 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 27 | import * as DateTime from "effect/DateTime"; | 28 | import * as DateTime from "effect/DateTime"; |
| 28 | import * as Option from "effect/Option"; | 29 | import * as Option from "effect/Option"; |
| 29 | import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; | 30 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 30 | import { | 31 | import { |
| 31 | isAtomCommandInterrupted, | 32 | isAtomCommandInterrupted, |
| 32 | squashAtomCommandFailure, | 33 | squashAtomCommandFailure, |
| ... | @@ -38,7 +39,6 @@ import { | ... | @@ -38,7 +39,6 @@ import { |
| 38 | } from "../../lib/resourceTelemetryState"; | 39 | } from "../../lib/resourceTelemetryState"; |
| 39 | import { cn } from "../../lib/utils"; | 40 | import { cn } from "../../lib/utils"; |
| 40 | import { ensureLocalApi } from "../../localApi"; | 41 | import { ensureLocalApi } from "../../localApi"; |
| 41 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 42 | import { serverEnvironment } from "../../state/server"; | 42 | import { serverEnvironment } from "../../state/server"; |
| 43 | import { useAtomCommand } from "../../state/use-atom-command"; | 43 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 44 | import { formatRelativeTime } from "../../timestampFormat"; | 44 | import { formatRelativeTime } from "../../timestampFormat"; |
| ... | @@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttribut | ... | @@ -831,31 +831,43 @@ function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttribut |
| 831 | ); | 831 | ); |
| 832 | } | 832 | } |
| 833 | 833 | ||
| 834 | export function ResourceTelemetryDiagnostics() { | 834 | export function ResourceTelemetryDiagnostics({ |
| 835 | environmentId, | ||
| 836 | }: { | ||
| 837 | environmentId: EnvironmentId | null; | ||
| 838 | }) { | ||
| 835 | const [windowMs, setWindowMs] = useState(15 * 60_000); | 839 | const [windowMs, setWindowMs] = useState(15 * 60_000); |
| 836 | const selectedWindow = | 840 | const selectedWindow = |
| 837 | HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; | 841 | HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; |
| 838 | const telemetry = useResourceTelemetry(); | 842 | const telemetry = useResourceTelemetry(environmentId); |
| 839 | const retryTelemetry = telemetry.retry; | 843 | const retryTelemetry = telemetry.retry; |
| 840 | const history = useResourceTelemetryHistory({ | 844 | const history = useResourceTelemetryHistory( |
| 841 | windowMs: selectedWindow.windowMs, | 845 | { |
| 842 | bucketMs: selectedWindow.bucketMs, | 846 | windowMs: selectedWindow.windowMs, |
| 843 | }); | 847 | bucketMs: selectedWindow.bucketMs, |
| 844 | const primaryEnvironment = usePrimaryEnvironment(); | 848 | }, |
| 849 | environmentId, | ||
| 850 | ); | ||
| 845 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { | 851 | const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { |
| 846 | reportFailure: false, | 852 | reportFailure: false, |
| 847 | }); | 853 | }); |
| 848 | const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set()); | 854 | const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set()); |
| 849 | const signalingKeysRef = useRef<ReadonlySet<string>>(new Set()); | 855 | const signalingKeysRef = useRef<ReadonlySet<string>>(new Set()); |
| 850 | signalingKeysRef.current = signalingKeys; | 856 | const environmentIdRef = useRef(environmentId); |
| 851 | const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId); | 857 | useEffect(() => { |
| 852 | primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId; | 858 | environmentIdRef.current = environmentId; |
| 859 | return () => { | ||
| 860 | environmentIdRef.current = null; | ||
| 861 | }; | ||
| 862 | }, [environmentId]); | ||
| 853 | const [isRetrying, setIsRetrying] = useState(false); | 863 | const [isRetrying, setIsRetrying] = useState(false); |
| 854 | const snapshot = telemetry.data; | 864 | const snapshot = telemetry.data; |
| 855 | const allT3 = snapshot?.groups.allT3; | 865 | const allT3 = snapshot?.groups.allT3; |
| 856 | 866 | ||
| 857 | const signalProcess = useCallback( | 867 | const signalProcess = useCallback( |
| 858 | async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { | 868 | async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { |
| 869 | const targetEnvironmentId = environmentIdRef.current; | ||
| 870 | if (targetEnvironmentId === null) return; | ||
| 859 | const identityKey = processIdentityKey(process); | 871 | const identityKey = processIdentityKey(process); |
| 860 | if (signalingKeysRef.current.has(identityKey)) return; | 872 | if (signalingKeysRef.current.has(identityKey)) return; |
| 861 | const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); | 873 | const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); |
| ... | @@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() { | ... | @@ -889,13 +901,12 @@ export function ResourceTelemetryDiagnostics() { |
| 889 | return; | 901 | return; |
| 890 | } | 902 | } |
| 891 | } | 903 | } |
| 892 | const environmentId = primaryEnvironmentIdRef.current; | 904 | if (environmentIdRef.current !== targetEnvironmentId) { |
| 893 | if (environmentId === undefined) { | ||
| 894 | clearSignaling(); | 905 | clearSignaling(); |
| 895 | return; | 906 | return; |
| 896 | } | 907 | } |
| 897 | void signalServerProcess({ | 908 | void signalServerProcess({ |
| 898 | environmentId, | 909 | environmentId: targetEnvironmentId, |
| 899 | input: { | 910 | input: { |
| 900 | pid: process.identity.pid, | 911 | pid: process.identity.pid, |
| 901 | startTimeMs: process.identity.startTimeMs, | 912 | startTimeMs: process.identity.startTimeMs, |
apps/web/src/components/settings/ScopedSwitch.tsx created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import type { ServerSettings } from "@t3tools/contracts"; | ||
| 2 | import type { ComponentProps } from "react"; | ||
| 3 | |||
| 4 | import { Switch } from "../ui/switch"; | ||
| 5 | import { useScopedSettingsMixed } from "./useScopedSettings"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * A switch for a server setting that renders the mixed state when the | ||
| 9 | * selected targets disagree on `settingKeys`. Clicking a mixed switch turns | ||
| 10 | * it on everywhere, the macOS mixed-checkbox convention. | ||
| 11 | */ | ||
| 12 | export function ScopedSwitch({ | ||
| 13 | settingKeys, | ||
| 14 | checked, | ||
| 15 | ...props | ||
| 16 | }: ComponentProps<typeof Switch> & { settingKeys: readonly (keyof ServerSettings)[] }) { | ||
| 17 | const mixed = useScopedSettingsMixed(settingKeys); | ||
| 18 | return <Switch {...props} mixed={mixed} checked={mixed ? false : checked} />; | ||
| 19 | } | ||
apps/web/src/components/settings/SettingInheritance.test.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 2 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { settingInheritanceLayers } from "./SettingInheritance"; | ||
| 6 | |||
| 7 | const environmentId = EnvironmentId.make("laptop"); | ||
| 8 | const projectId = ProjectId.make("project"); | ||
| 9 | |||
| 10 | describe("settingInheritanceLayers", () => { | ||
| 11 | it("marks the built-in default effective when nothing is set", () => { | ||
| 12 | const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null); | ||
| 13 | const layers = settingInheritanceLayers( | ||
| 14 | { environmentId, label: "Laptop", projectId: null, ...resolved }, | ||
| 15 | DEFAULT_SERVER_SETTINGS, | ||
| 16 | "defaultAutoPull", | ||
| 17 | ); | ||
| 18 | expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ | ||
| 19 | ["Laptop", "Inherits", false], | ||
| 20 | ["Default", "Off", true], | ||
| 21 | ]); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("walks project override, environment value, then built-in default", () => { | ||
| 25 | const settings = { | ||
| 26 | ...DEFAULT_SERVER_SETTINGS, | ||
| 27 | defaultAutoPull: true, | ||
| 28 | projectSettingsOverrides: { [projectId]: { defaultAutoPull: false } }, | ||
| 29 | }; | ||
| 30 | const resolved = resolveProjectSettings(settings, projectId); | ||
| 31 | const layers = settingInheritanceLayers( | ||
| 32 | { environmentId, label: "Laptop", projectId, ...resolved }, | ||
| 33 | settings, | ||
| 34 | "defaultAutoPull", | ||
| 35 | ); | ||
| 36 | expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ | ||
| 37 | ["Project", "Off", true], | ||
| 38 | ["Laptop", "On", false], | ||
| 39 | ["Default", "Off", false], | ||
| 40 | ]); | ||
| 41 | const inherited = settingInheritanceLayers( | ||
| 42 | { | ||
| 43 | environmentId, | ||
| 44 | label: "Laptop", | ||
| 45 | projectId, | ||
| 46 | ...resolveProjectSettings(settings, ProjectId.make("other")), | ||
| 47 | }, | ||
| 48 | settings, | ||
| 49 | "defaultAutoPull", | ||
| 50 | ); | ||
| 51 | expect(inherited.map((layer) => [layer.value, layer.effective])).toEqual([ | ||
| 52 | ["Inherits", false], | ||
| 53 | ["On", true], | ||
| 54 | ["Off", false], | ||
| 55 | ]); | ||
| 56 | }); | ||
| 57 | }); | ||
apps/web/src/components/settings/SettingInheritance.tsx created+303| ... | @@ -0,0 +1,303 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | resolveEnvironmentMachineKind, | ||
| 4 | type ServerSettings, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { CheckIcon, LayersIcon } from "lucide-react"; | ||
| 7 | import * as Equal from "effect/Equal"; | ||
| 8 | |||
| 9 | import { cn } from "../../lib/utils"; | ||
| 10 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 11 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 12 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; | ||
| 13 | import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; | ||
| 14 | import { Button } from "../ui/button"; | ||
| 15 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 17 | import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings"; | ||
| 18 | import { isProjectScopedSettingKey } from "./scopedSettings"; | ||
| 19 | |||
| 20 | interface InheritanceLayer { | ||
| 21 | readonly key: "project" | "environment" | "built-in"; | ||
| 22 | readonly label: string; | ||
| 23 | readonly value: string; | ||
| 24 | readonly effective: boolean; | ||
| 25 | readonly set: boolean; | ||
| 26 | } | ||
| 27 | |||
| 28 | const WRITING_STYLE_LABELS: Record<string, string> = { | ||
| 29 | repo_conventions: "Repository conventions", | ||
| 30 | conventional_commits: "Conventional Commits", | ||
| 31 | custom: "Custom instructions", | ||
| 32 | }; | ||
| 33 | |||
| 34 | /** Human labels for the values the chain can show; falls back to a type summary. */ | ||
| 35 | function formatValue(key: keyof ServerSettings, value: unknown): string { | ||
| 36 | if (value === null || value === undefined) { | ||
| 37 | return key === "pullRequestMergeMethod" | ||
| 38 | ? "Last selected" | ||
| 39 | : key === "sidebarAutoSettleAfterDays" | ||
| 40 | ? "Never" | ||
| 41 | : key === "defaultModelSelection" | ||
| 42 | ? "Automatic" | ||
| 43 | : key === "sourceControlWriterModelSelection" | ||
| 44 | ? "Text generation model" | ||
| 45 | : "Not set"; | ||
| 46 | } | ||
| 47 | if (typeof value === "boolean") return value ? "On" : "Off"; | ||
| 48 | if (typeof value === "number") { | ||
| 49 | return key === "sidebarAutoSettleAfterDays" | ||
| 50 | ? `${value} ${value === 1 ? "day" : "days"}` | ||
| 51 | : String(value); | ||
| 52 | } | ||
| 53 | if (typeof value === "string") { | ||
| 54 | if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { | ||
| 55 | return resolveEnvModeLabel(value); | ||
| 56 | } | ||
| 57 | if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { | ||
| 58 | return PULL_REQUEST_MERGE_METHOD_LABELS[ | ||
| 59 | value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS | ||
| 60 | ]; | ||
| 61 | } | ||
| 62 | return value === "" ? "Empty" : value; | ||
| 63 | } | ||
| 64 | if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`; | ||
| 65 | if (typeof value === "object") { | ||
| 66 | if ("model" in value && typeof value.model === "string") return value.model; | ||
| 67 | if ("mode" in value && typeof value.mode === "string") { | ||
| 68 | return WRITING_STYLE_LABELS[value.mode] ?? value.mode; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return "Custom"; | ||
| 72 | } | ||
| 73 | |||
| 74 | /** | ||
| 75 | * The layers a setting resolves through for one target, top-down: the | ||
| 76 | * project override when the target is a project, the environment's value, | ||
| 77 | * and the built-in default. The first layer that is set wins. | ||
| 78 | */ | ||
| 79 | export function settingInheritanceLayers( | ||
| 80 | target: ScopedSettingsTarget, | ||
| 81 | environmentSettings: ServerSettings, | ||
| 82 | key: keyof ServerSettings, | ||
| 83 | ): readonly InheritanceLayer[] { | ||
| 84 | const builtIn = DEFAULT_SERVER_SETTINGS[key]; | ||
| 85 | const environmentValue = environmentSettings[key]; | ||
| 86 | const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; | ||
| 87 | const environmentSet = !Equal.equals(environmentValue, builtIn); | ||
| 88 | const layers: InheritanceLayer[] = []; | ||
| 89 | if (target.projectId !== null && isProjectScopedSettingKey(key)) { | ||
| 90 | layers.push({ | ||
| 91 | key: "project", | ||
| 92 | label: "Project", | ||
| 93 | value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits", | ||
| 94 | effective: projectSource === "project", | ||
| 95 | set: projectSource === "project", | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | layers.push({ | ||
| 99 | key: "environment", | ||
| 100 | label: target.label, | ||
| 101 | value: environmentSet ? formatValue(key, environmentValue) : "Inherits", | ||
| 102 | effective: projectSource !== "project" && environmentSet, | ||
| 103 | set: environmentSet, | ||
| 104 | }); | ||
| 105 | layers.push({ | ||
| 106 | key: "built-in", | ||
| 107 | label: "Default", | ||
| 108 | value: formatValue(key, builtIn), | ||
| 109 | effective: projectSource !== "project" && !environmentSet, | ||
| 110 | set: true, | ||
| 111 | }); | ||
| 112 | return layers; | ||
| 113 | } | ||
| 114 | |||
| 115 | export type SettingInheritanceState = | ||
| 116 | | "default" | ||
| 117 | | "environment" | ||
| 118 | | "inherited" | ||
| 119 | | "overridden" | ||
| 120 | | "mixed"; | ||
| 121 | |||
| 122 | /** | ||
| 123 | * A small indicator beside a row's title that opens a top-down view of where | ||
| 124 | * the setting's value comes from on each selected target. It sits inline so | ||
| 125 | * narrowing to a project does not add a caption line to every row. | ||
| 126 | */ | ||
| 127 | export interface SettingOverridingProject extends ProjectOverrideEntry { | ||
| 128 | readonly label: string; | ||
| 129 | /** Jumps the breadcrumb to this project so its override can be edited. */ | ||
| 130 | readonly open: () => void; | ||
| 131 | } | ||
| 132 | |||
| 133 | export function SettingInheritance({ | ||
| 134 | state, | ||
| 135 | summary, | ||
| 136 | targets, | ||
| 137 | environments, | ||
| 138 | keys, | ||
| 139 | overridingProjects = [], | ||
| 140 | onClearOverrides, | ||
| 141 | }: { | ||
| 142 | state: SettingInheritanceState; | ||
| 143 | summary: string; | ||
| 144 | targets: readonly ScopedSettingsTarget[]; | ||
| 145 | environments: readonly Pick<EnvironmentPresentation, "environmentId" | "serverConfig">[]; | ||
| 146 | keys: readonly (keyof ServerSettings)[]; | ||
| 147 | /** At environment scope: projects whose own value hides the environment's. */ | ||
| 148 | overridingProjects?: readonly SettingOverridingProject[]; | ||
| 149 | onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void; | ||
| 150 | }) { | ||
| 151 | const key = keys[0]; | ||
| 152 | if (!key || targets.length === 0) return null; | ||
| 153 | const overrideSummary = | ||
| 154 | overridingProjects.length > 0 | ||
| 155 | ? `${summary} · ${overridingProjects.length} project ${overridingProjects.length === 1 ? "override" : "overrides"}` | ||
| 156 | : summary; | ||
| 157 | const chains = targets.flatMap((target) => { | ||
| 158 | const environment = environments.find( | ||
| 159 | (candidate) => candidate.environmentId === target.environmentId, | ||
| 160 | ); | ||
| 161 | if (!environment?.serverConfig) return []; | ||
| 162 | return [ | ||
| 163 | { | ||
| 164 | target, | ||
| 165 | environment: { ...environment, serverConfig: environment.serverConfig }, | ||
| 166 | machine: resolveEnvironmentMachineKind(environment.serverConfig), | ||
| 167 | layers: settingInheritanceLayers(target, environment.serverConfig.settings, key), | ||
| 168 | }, | ||
| 169 | ]; | ||
| 170 | }); | ||
| 171 | return ( | ||
| 172 | <Popover> | ||
| 173 | <Tooltip> | ||
| 174 | <TooltipTrigger | ||
| 175 | render={ | ||
| 176 | <PopoverTrigger | ||
| 177 | render={ | ||
| 178 | <Button | ||
| 179 | size="icon-micro" | ||
| 180 | variant="ghost-muted" | ||
| 181 | aria-label={`${overrideSummary}. Show where this value comes from`} | ||
| 182 | className={cn( | ||
| 183 | "[--control-icon-color:currentColor]", | ||
| 184 | state === "overridden" | ||
| 185 | ? "text-primary hover:text-primary" | ||
| 186 | : state === "mixed" | ||
| 187 | ? "text-warning hover:text-warning" | ||
| 188 | : state === "environment" | ||
| 189 | ? "text-foreground/70 hover:text-foreground" | ||
| 190 | : "text-muted-foreground/60 hover:text-foreground", | ||
| 191 | )} | ||
| 192 | /> | ||
| 193 | } | ||
| 194 | /> | ||
| 195 | } | ||
| 196 | > | ||
| 197 | <LayersIcon className="size-3" /> | ||
| 198 | </TooltipTrigger> | ||
| 199 | <TooltipPopup side="top">{overrideSummary}</TooltipPopup> | ||
| 200 | </Tooltip> | ||
| 201 | <PopoverPopup | ||
| 202 | align="start" | ||
| 203 | className="w-72 max-w-[calc(100vw-2rem)]" | ||
| 204 | viewportClassName="p-0 [--viewport-inline-padding:0px]" | ||
| 205 | > | ||
| 206 | <div className="divide-y divide-border/60"> | ||
| 207 | {chains.map(({ target, environment, machine, layers }) => ( | ||
| 208 | <section | ||
| 209 | key={`${target.environmentId}:${target.projectId ?? ""}`} | ||
| 210 | className="px-3 py-2.5" | ||
| 211 | > | ||
| 212 | <h4 className="flex items-center gap-1.5 pb-1.5 text-xs font-medium text-muted-foreground"> | ||
| 213 | <EnvironmentMachineIcon aria-hidden kind={machine} className="size-3.5 shrink-0" /> | ||
| 214 | <span className="min-w-0 truncate">{target.label}</span> | ||
| 215 | </h4> | ||
| 216 | <ol role="list" className="text-sm"> | ||
| 217 | {layers.map((layer) => ( | ||
| 218 | <li | ||
| 219 | key={layer.key} | ||
| 220 | className={cn( | ||
| 221 | "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1", | ||
| 222 | layer.effective && "bg-foreground/[0.06]", | ||
| 223 | )} | ||
| 224 | > | ||
| 225 | <span | ||
| 226 | className={cn( | ||
| 227 | "min-w-0 truncate", | ||
| 228 | layer.effective ? "font-medium text-foreground" : "text-muted-foreground", | ||
| 229 | )} | ||
| 230 | > | ||
| 231 | {layer.key === "environment" ? "Environment" : layer.label} | ||
| 232 | </span> | ||
| 233 | <span | ||
| 234 | className={cn( | ||
| 235 | "flex items-center gap-1.5 tabular-nums", | ||
| 236 | layer.effective | ||
| 237 | ? "text-foreground" | ||
| 238 | : layer.set | ||
| 239 | ? "text-muted-foreground" | ||
| 240 | : "text-muted-foreground/60", | ||
| 241 | )} | ||
| 242 | > | ||
| 243 | <span className="max-w-32 truncate">{layer.value}</span> | ||
| 244 | {layer.effective ? ( | ||
| 245 | <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" /> | ||
| 246 | ) : ( | ||
| 247 | <span aria-hidden className="size-3.5 shrink-0" /> | ||
| 248 | )} | ||
| 249 | </span> | ||
| 250 | </li> | ||
| 251 | ))} | ||
| 252 | </ol> | ||
| 253 | {(() => { | ||
| 254 | const overriding = overridingProjects.filter( | ||
| 255 | (project) => project.environmentId === target.environmentId, | ||
| 256 | ); | ||
| 257 | if (overriding.length === 0) return null; | ||
| 258 | const overrides = environment.serverConfig.settings.projectSettingsOverrides; | ||
| 259 | return ( | ||
| 260 | <div className="mt-2 border-t border-border/60 pt-2"> | ||
| 261 | <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground"> | ||
| 262 | <span>Overridden by</span> | ||
| 263 | {onClearOverrides ? ( | ||
| 264 | <button | ||
| 265 | type="button" | ||
| 266 | className="cursor-pointer font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" | ||
| 267 | onClick={() => onClearOverrides(overriding)} | ||
| 268 | > | ||
| 269 | Reset {overriding.length === 1 ? "it" : "all"} | ||
| 270 | </button> | ||
| 271 | ) : null} | ||
| 272 | </div> | ||
| 273 | <ul role="list" className="mt-0.5 text-sm"> | ||
| 274 | {overriding.map((project) => ( | ||
| 275 | <li | ||
| 276 | key={project.projectId} | ||
| 277 | className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1" | ||
| 278 | > | ||
| 279 | <button | ||
| 280 | type="button" | ||
| 281 | className="min-w-0 cursor-pointer truncate text-left text-foreground underline-offset-2 hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" | ||
| 282 | onClick={project.open} | ||
| 283 | > | ||
| 284 | {project.label} | ||
| 285 | </button> | ||
| 286 | <span className="max-w-32 truncate text-muted-foreground tabular-nums"> | ||
| 287 | {isProjectScopedSettingKey(key) | ||
| 288 | ? formatValue(key, overrides[project.projectId]?.[key]) | ||
| 289 | : null} | ||
| 290 | </span> | ||
| 291 | </li> | ||
| 292 | ))} | ||
| 293 | </ul> | ||
| 294 | </div> | ||
| 295 | ); | ||
| 296 | })()} | ||
| 297 | </section> | ||
| 298 | ))} | ||
| 299 | </div> | ||
| 300 | </PopoverPopup> | ||
| 301 | </Popover> | ||
| 302 | ); | ||
| 303 | } | ||
apps/web/src/components/settings/SettingsBreadcrumb.tsx+201-1| ... | @@ -1,9 +1,37 @@ | ... | @@ -1,9 +1,37 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | import { LayersIcon } from "lucide-react"; | ||
| 3 | import type { ReactNode } from "react"; | ||
| 4 | |||
| 5 | import { cn } from "../../lib/utils"; | ||
| 6 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | ||
| 7 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 8 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 9 | import { ProjectFavicon } from "../ProjectFavicon"; | ||
| 10 | import { | ||
| 11 | Menu, | ||
| 12 | MenuPopup, | ||
| 13 | MenuRadioGroup, | ||
| 14 | MenuRadioItem, | ||
| 15 | MenuRadioItemIndicator, | ||
| 16 | MenuSeparator, | ||
| 17 | MenuTrigger, | ||
| 18 | } from "../ui/menu"; | ||
| 1 | import { | 19 | import { |
| 2 | WorkspaceBreadcrumb, | 20 | WorkspaceBreadcrumb, |
| 3 | WorkspaceBreadcrumbItem, | 21 | WorkspaceBreadcrumbItem, |
| 4 | WorkspaceBreadcrumbSeparator, | 22 | WorkspaceBreadcrumbSeparator, |
| 5 | } from "../WorkspaceBreadcrumb"; | 23 | } from "../WorkspaceBreadcrumb"; |
| 6 | import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; | 24 | import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; |
| 25 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | ||
| 26 | import { | ||
| 27 | ALL_ENVIRONMENTS_VALUE, | ||
| 28 | ALL_PROJECTS_VALUE, | ||
| 29 | environmentAxisValue, | ||
| 30 | projectAxisValue, | ||
| 31 | selectEnvironmentAxis, | ||
| 32 | selectProjectAxis, | ||
| 33 | settingsScopeEnvironmentLabel, | ||
| 34 | } from "./settingsScopeAxis"; | ||
| 7 | 35 | ||
| 8 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { | 36 | const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = { |
| 9 | ...SETTINGS_SECTION_LABELS, | 37 | ...SETTINGS_SECTION_LABELS, |
| ... | @@ -15,7 +43,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null { | ... | @@ -15,7 +43,27 @@ function settingsBreadcrumbLabel(pathname: string): string | null { |
| 15 | return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; | 43 | return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; |
| 16 | } | 44 | } |
| 17 | 45 | ||
| 18 | export function SettingsBreadcrumb({ pathname }: { pathname: string }) { | 46 | export interface SettingsScopeBreadcrumbProps { |
| 47 | readonly value: SettingsScopeSearch; | ||
| 48 | readonly groups: readonly SidebarProjectSnapshot[]; | ||
| 49 | readonly environments: readonly EnvironmentPresentation[]; | ||
| 50 | readonly onChange: (next: SettingsScopeSearch) => void; | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * `Settings / Section / Environment / Project`. The last two crumbs are the | ||
| 55 | * targets a change applies to and read like the usage page's filter: muted at | ||
| 56 | * "all", foreground once narrowed. A project is the same project on every | ||
| 57 | * environment, so the environment crumb alone decides where a project | ||
| 58 | * override is written. | ||
| 59 | */ | ||
| 60 | export function SettingsBreadcrumb({ | ||
| 61 | pathname, | ||
| 62 | scope, | ||
| 63 | }: { | ||
| 64 | pathname: string; | ||
| 65 | scope?: SettingsScopeBreadcrumbProps | undefined; | ||
| 66 | }) { | ||
| 19 | const sectionLabel = settingsBreadcrumbLabel(pathname); | 67 | const sectionLabel = settingsBreadcrumbLabel(pathname); |
| 20 | 68 | ||
| 21 | return ( | 69 | return ( |
| ... | @@ -29,6 +77,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { | ... | @@ -29,6 +77,158 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { |
| 29 | <WorkspaceBreadcrumbItem current className="truncate"> | 77 | <WorkspaceBreadcrumbItem current className="truncate"> |
| 30 | {sectionLabel ?? "Settings"} | 78 | {sectionLabel ?? "Settings"} |
| 31 | </WorkspaceBreadcrumbItem> | 79 | </WorkspaceBreadcrumbItem> |
| 80 | {scope ? ( | ||
| 81 | <> | ||
| 82 | <WorkspaceBreadcrumbSeparator /> | ||
| 83 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | ||
| 84 | <EnvironmentScopeMenu {...scope} /> | ||
| 85 | </WorkspaceBreadcrumbItem> | ||
| 86 | <WorkspaceBreadcrumbSeparator /> | ||
| 87 | <WorkspaceBreadcrumbItem className="min-w-0 shrink"> | ||
| 88 | <ProjectScopeMenu {...scope} /> | ||
| 89 | </WorkspaceBreadcrumbItem> | ||
| 90 | </> | ||
| 91 | ) : null} | ||
| 32 | </WorkspaceBreadcrumb> | 92 | </WorkspaceBreadcrumb> |
| 33 | ); | 93 | ); |
| 34 | } | 94 | } |
| 95 | |||
| 96 | function ScopeMenu({ | ||
| 97 | ariaLabel, | ||
| 98 | icon, | ||
| 99 | label, | ||
| 100 | narrowed, | ||
| 101 | children, | ||
| 102 | }: { | ||
| 103 | ariaLabel: string; | ||
| 104 | icon: ReactNode; | ||
| 105 | label: string; | ||
| 106 | narrowed: boolean; | ||
| 107 | children: ReactNode; | ||
| 108 | }) { | ||
| 109 | return ( | ||
| 110 | <Menu> | ||
| 111 | <MenuTrigger | ||
| 112 | aria-label={ariaLabel} | ||
| 113 | className={cn( | ||
| 114 | "inline-flex min-w-0 max-w-56 cursor-pointer items-center gap-1.5 rounded-sm text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", | ||
| 115 | narrowed ? "text-foreground" : "text-muted-foreground hover:text-foreground", | ||
| 116 | )} | ||
| 117 | > | ||
| 118 | {icon} | ||
| 119 | <span className="min-w-0 truncate">{label}</span> | ||
| 120 | </MenuTrigger> | ||
| 121 | <MenuPopup align="start" className="w-64 max-w-[calc(100vw-2rem)]"> | ||
| 122 | {children} | ||
| 123 | </MenuPopup> | ||
| 124 | </Menu> | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | |||
| 128 | function EnvironmentScopeMenu({ | ||
| 129 | value, | ||
| 130 | groups, | ||
| 131 | environments, | ||
| 132 | onChange, | ||
| 133 | }: SettingsScopeBreadcrumbProps) { | ||
| 134 | const resolved = resolveSettingsScope(value, groups, environments); | ||
| 135 | const environmentValue = environmentAxisValue( | ||
| 136 | value, | ||
| 137 | resolved.kind === "checkout" ? resolved.environmentId : null, | ||
| 138 | ); | ||
| 139 | const selected = environments.find( | ||
| 140 | (environment) => environment.environmentId === environmentValue, | ||
| 141 | ); | ||
| 142 | return ( | ||
| 143 | <ScopeMenu | ||
| 144 | ariaLabel="Environment scope" | ||
| 145 | narrowed={environmentValue !== ALL_ENVIRONMENTS_VALUE} | ||
| 146 | icon={ | ||
| 147 | selected ? ( | ||
| 148 | <EnvironmentMachineIcon | ||
| 149 | aria-hidden | ||
| 150 | kind={resolveEnvironmentMachineKind(selected.serverConfig)} | ||
| 151 | className="size-3.5 shrink-0" | ||
| 152 | /> | ||
| 153 | ) : null | ||
| 154 | } | ||
| 155 | label={ | ||
| 156 | selected | ||
| 157 | ? settingsScopeEnvironmentLabel(selected, environments) | ||
| 158 | : environmentValue !== ALL_ENVIRONMENTS_VALUE | ||
| 159 | ? "Unavailable environment" | ||
| 160 | : "All environments" | ||
| 161 | } | ||
| 162 | > | ||
| 163 | <MenuRadioGroup | ||
| 164 | value={environmentValue} | ||
| 165 | onValueChange={(next) => { | ||
| 166 | if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); | ||
| 167 | }} | ||
| 168 | > | ||
| 169 | <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}> | ||
| 170 | <span className="flex min-w-0 items-center gap-2"> | ||
| 171 | <LayersIcon aria-hidden className="size-3.5" /> | ||
| 172 | <span className="min-w-0 flex-1 truncate">All environments</span> | ||
| 173 | <MenuRadioItemIndicator /> | ||
| 174 | </span> | ||
| 175 | </MenuRadioItem> | ||
| 176 | <MenuSeparator /> | ||
| 177 | {environments.map((environment) => ( | ||
| 178 | <MenuRadioItem key={environment.environmentId} value={environment.environmentId}> | ||
| 179 | <span className="flex min-w-0 items-center gap-2"> | ||
| 180 | <EnvironmentMachineIcon | ||
| 181 | aria-hidden | ||
| 182 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 183 | className="size-3.5" | ||
| 184 | /> | ||
| 185 | <span className="min-w-0 flex-1 truncate"> | ||
| 186 | {settingsScopeEnvironmentLabel(environment, environments)} | ||
| 187 | </span> | ||
| 188 | {environment.connection.phase === "connected" ? null : ( | ||
| 189 | <span className="shrink-0 text-xs text-muted-foreground">Offline</span> | ||
| 190 | )} | ||
| 191 | <MenuRadioItemIndicator /> | ||
| 192 | </span> | ||
| 193 | </MenuRadioItem> | ||
| 194 | ))} | ||
| 195 | </MenuRadioGroup> | ||
| 196 | </ScopeMenu> | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | |||
| 200 | function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbProps) { | ||
| 201 | const selected = groups.find((group) => group.projectKey === value.project); | ||
| 202 | return ( | ||
| 203 | <ScopeMenu | ||
| 204 | ariaLabel="Project scope" | ||
| 205 | narrowed={value.project !== undefined} | ||
| 206 | icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null} | ||
| 207 | label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")} | ||
| 208 | > | ||
| 209 | <MenuRadioGroup | ||
| 210 | value={projectAxisValue(value)} | ||
| 211 | onValueChange={(next) => { | ||
| 212 | if (typeof next === "string") onChange(selectProjectAxis(value, next)); | ||
| 213 | }} | ||
| 214 | > | ||
| 215 | <MenuRadioItem value={ALL_PROJECTS_VALUE}> | ||
| 216 | <span className="flex min-w-0 items-center gap-2"> | ||
| 217 | <span className="min-w-0 flex-1 truncate">All projects</span> | ||
| 218 | <MenuRadioItemIndicator /> | ||
| 219 | </span> | ||
| 220 | </MenuRadioItem> | ||
| 221 | <MenuSeparator /> | ||
| 222 | {groups.map((group) => ( | ||
| 223 | <MenuRadioItem key={group.projectKey} value={group.projectKey}> | ||
| 224 | <span className="flex min-w-0 items-center gap-2"> | ||
| 225 | <ProjectFavicon project={group} className="size-3.5" /> | ||
| 226 | <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | ||
| 227 | <MenuRadioItemIndicator /> | ||
| 228 | </span> | ||
| 229 | </MenuRadioItem> | ||
| 230 | ))} | ||
| 231 | </MenuRadioGroup> | ||
| 232 | </ScopeMenu> | ||
| 233 | ); | ||
| 234 | } |
apps/web/src/components/settings/SettingsPanels.tsx+153-119| ... | @@ -3,7 +3,6 @@ import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-re | ... | @@ -3,7 +3,6 @@ import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-re |
| 3 | import { Link, useNavigate } from "@tanstack/react-router"; | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | import type { CSSProperties, ReactNode } from "react"; | 4 | import type { CSSProperties, ReactNode } from "react"; |
| 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 6 | import { useAtomValue } from "@effect/atom-react"; | ||
| 7 | import { | 6 | import { |
| 8 | type BackgroundActivityProfile, | 7 | type BackgroundActivityProfile, |
| 9 | type DesktopUpdateChannel, | 8 | type DesktopUpdateChannel, |
| ... | @@ -70,13 +69,19 @@ import { | ... | @@ -70,13 +69,19 @@ import { |
| 70 | useTheme, | 69 | useTheme, |
| 71 | } from "../../hooks/useTheme"; | 70 | } from "../../hooks/useTheme"; |
| 72 | import { useLocalStorage } from "../../hooks/useLocalStorage"; | 71 | import { useLocalStorage } from "../../hooks/useLocalStorage"; |
| 73 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 72 | import { |
| 73 | useScopedSettings, | ||
| 74 | useScopedSettingsMixed, | ||
| 75 | useUpdateScopedSettings, | ||
| 76 | } from "./useScopedSettings"; | ||
| 77 | import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; | ||
| 78 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 79 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | ||
| 74 | import { useThreadActions } from "../../hooks/useThreadActions"; | 80 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| 75 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | 81 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 76 | import { | 82 | import { |
| 77 | getCustomModelOptionsByInstance, | 83 | getCustomModelOptionsByInstance, |
| 78 | resolveAppModelSelectionState, | 84 | resolveAppModelSelectionState, |
| 79 | withoutPlanAgentSelection, | ||
| 80 | } from "../../modelSelection"; | 85 | } from "../../modelSelection"; |
| 81 | import { | 86 | import { |
| 82 | applyProviderInstanceSettings, | 87 | applyProviderInstanceSettings, |
| ... | @@ -85,13 +90,7 @@ import { | ... | @@ -85,13 +90,7 @@ import { |
| 85 | } from "../../providerInstances"; | 90 | } from "../../providerInstances"; |
| 86 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 91 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 87 | import { isMacPlatform } from "../../lib/utils"; | 92 | import { isMacPlatform } from "../../lib/utils"; |
| 88 | import { | 93 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 89 | primaryServerConfigAtom, | ||
| 90 | primaryServerObservabilityAtom, | ||
| 91 | primaryServerProvidersAtom, | ||
| 92 | } from "../../state/server"; | ||
| 93 | import { useProjects } from "../../state/entities"; | ||
| 94 | import { usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 95 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 94 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 96 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | 95 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 97 | import { Button } from "../ui/button"; | 96 | import { Button } from "../ui/button"; |
| ... | @@ -118,7 +117,6 @@ import { | ... | @@ -118,7 +117,6 @@ import { |
| 118 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, | 117 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 119 | } from "../../appearanceFonts"; | 118 | } from "../../appearanceFonts"; |
| 120 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; | 119 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 121 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | ||
| 122 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; | 120 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; |
| 123 | import { | 121 | import { |
| 124 | NumberField, | 122 | NumberField, |
| ... | @@ -129,6 +127,7 @@ import { | ... | @@ -129,6 +127,7 @@ import { |
| 129 | } from "../ui/number-field"; | 127 | } from "../ui/number-field"; |
| 130 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 128 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 131 | import { Switch } from "../ui/switch"; | 129 | import { Switch } from "../ui/switch"; |
| 130 | import { ScopedSwitch } from "./ScopedSwitch"; | ||
| 132 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 131 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 133 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 132 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 134 | import { ThemeLibrary } from "./ThemeSettings"; | 133 | import { ThemeLibrary } from "./ThemeSettings"; |
| ... | @@ -136,7 +135,6 @@ import { | ... | @@ -136,7 +135,6 @@ import { |
| 136 | backgroundActivityOverrideSettings, | 135 | backgroundActivityOverrideSettings, |
| 137 | backgroundActivitySharedPolicySettings, | 136 | backgroundActivitySharedPolicySettings, |
| 138 | durationToSeconds, | 137 | durationToSeconds, |
| 139 | formatDiagnosticsDescription, | ||
| 140 | getChangedBrowserSettingLabels, | 138 | getChangedBrowserSettingLabels, |
| 141 | getChangedTypographySettingLabels, | 139 | getChangedTypographySettingLabels, |
| 142 | normalizeIntervalSeconds, | 140 | normalizeIntervalSeconds, |
| ... | @@ -488,8 +486,8 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -488,8 +486,8 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 488 | clearThemeHalves, | 486 | clearThemeHalves, |
| 489 | themeHalves, | 487 | themeHalves, |
| 490 | } = useTheme(); | 488 | } = useTheme(); |
| 491 | const settings = usePrimarySettings(); | 489 | const settings = useScopedSettings(); |
| 492 | const updateSettings = useUpdatePrimarySettings(); | 490 | const updateSettings = useUpdateScopedSettings(); |
| 493 | 491 | ||
| 494 | const isTextGenerationModelDirty = !Equal.equals( | 492 | const isTextGenerationModelDirty = !Equal.equals( |
| 495 | settings.textGenerationModelSelection ?? null, | 493 | settings.textGenerationModelSelection ?? null, |
| ... | @@ -781,8 +779,8 @@ function BackgroundActivityAdvancedDialog({ | ... | @@ -781,8 +779,8 @@ function BackgroundActivityAdvancedDialog({ |
| 781 | readonly open: boolean; | 779 | readonly open: boolean; |
| 782 | readonly onOpenChange: (open: boolean) => void; | 780 | readonly onOpenChange: (open: boolean) => void; |
| 783 | }) { | 781 | }) { |
| 784 | const settings = usePrimarySettings(); | 782 | const settings = useScopedSettings(); |
| 785 | const updateSettings = useUpdatePrimarySettings(); | 783 | const updateSettings = useUpdateScopedSettings(); |
| 786 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | 784 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| 787 | const activeProfile = resolvedBackgroundActivity.profile; | 785 | const activeProfile = resolvedBackgroundActivity.profile; |
| 788 | const automaticGitFetchIntervalSeconds = durationToSeconds( | 786 | const automaticGitFetchIntervalSeconds = durationToSeconds( |
| ... | @@ -1059,8 +1057,8 @@ export function AppearanceSettingsPanel() { | ... | @@ -1059,8 +1057,8 @@ export function AppearanceSettingsPanel() { |
| 1059 | } = useTheme(); | 1057 | } = useTheme(); |
| 1060 | const customThemes = useCustomThemes(); | 1058 | const customThemes = useCustomThemes(); |
| 1061 | const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); | 1059 | const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); |
| 1062 | const settings = usePrimarySettings(); | 1060 | const settings = useScopedSettings(); |
| 1063 | const updateSettings = useUpdatePrimarySettings(); | 1061 | const updateSettings = useUpdateScopedSettings(); |
| 1064 | const environmentStageLabel = useEnvironmentStageLabel(); | 1062 | const environmentStageLabel = useEnvironmentStageLabel(); |
| 1065 | const showEnvironmentIdentification = | 1063 | const showEnvironmentIdentification = |
| 1066 | resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; | 1064 | resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; |
| ... | @@ -1353,7 +1351,7 @@ export function AppearanceSettingsPanel() { | ... | @@ -1353,7 +1351,7 @@ export function AppearanceSettingsPanel() { |
| 1353 | } | 1351 | } |
| 1354 | 1352 | ||
| 1355 | function useFontDefaultFamilies() { | 1353 | function useFontDefaultFamilies() { |
| 1356 | const settings = usePrimarySettings(); | 1354 | const settings = useScopedSettings(); |
| 1357 | // An unset preference shows the font it resolves to on this machine; the | 1355 | // An unset preference shows the font it resolves to on this machine; the |
| 1358 | // default stacks are the platform's own faces, so the name is probed, not | 1356 | // default stacks are the platform's own faces, so the name is probed, not |
| 1359 | // hardcoded. | 1357 | // hardcoded. |
| ... | @@ -1373,8 +1371,8 @@ function useFontDefaultFamilies() { | ... | @@ -1373,8 +1371,8 @@ function useFontDefaultFamilies() { |
| 1373 | } | 1371 | } |
| 1374 | 1372 | ||
| 1375 | function InterfaceFontRow({ preview }: { preview?: ReactNode }) { | 1373 | function InterfaceFontRow({ preview }: { preview?: ReactNode }) { |
| 1376 | const settings = usePrimarySettings(); | 1374 | const settings = useScopedSettings(); |
| 1377 | const updateSettings = useUpdatePrimarySettings(); | 1375 | const updateSettings = useUpdateScopedSettings(); |
| 1378 | const defaults = useFontDefaultFamilies(); | 1376 | const defaults = useFontDefaultFamilies(); |
| 1379 | return ( | 1377 | return ( |
| 1380 | <FontFamilySettingsRow | 1378 | <FontFamilySettingsRow |
| ... | @@ -1404,8 +1402,8 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) { | ... | @@ -1404,8 +1402,8 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) { |
| 1404 | } | 1402 | } |
| 1405 | 1403 | ||
| 1406 | function PromptFontRow() { | 1404 | function PromptFontRow() { |
| 1407 | const settings = usePrimarySettings(); | 1405 | const settings = useScopedSettings(); |
| 1408 | const updateSettings = useUpdatePrimarySettings(); | 1406 | const updateSettings = useUpdateScopedSettings(); |
| 1409 | const defaults = useFontDefaultFamilies(); | 1407 | const defaults = useFontDefaultFamilies(); |
| 1410 | return ( | 1408 | return ( |
| 1411 | <FontFamilySettingsRow | 1409 | <FontFamilySettingsRow |
| ... | @@ -1443,8 +1441,8 @@ function CodeFontRow({ | ... | @@ -1443,8 +1441,8 @@ function CodeFontRow({ |
| 1443 | description?: string; | 1441 | description?: string; |
| 1444 | preview?: ReactNode; | 1442 | preview?: ReactNode; |
| 1445 | }) { | 1443 | }) { |
| 1446 | const settings = usePrimarySettings(); | 1444 | const settings = useScopedSettings(); |
| 1447 | const updateSettings = useUpdatePrimarySettings(); | 1445 | const updateSettings = useUpdateScopedSettings(); |
| 1448 | const defaults = useFontDefaultFamilies(); | 1446 | const defaults = useFontDefaultFamilies(); |
| 1449 | return ( | 1447 | return ( |
| 1450 | <FontFamilySettingsRow | 1448 | <FontFamilySettingsRow |
| ... | @@ -1476,8 +1474,8 @@ function CodeFontRow({ | ... | @@ -1476,8 +1474,8 @@ function CodeFontRow({ |
| 1476 | } | 1474 | } |
| 1477 | 1475 | ||
| 1478 | function TerminalFontRow() { | 1476 | function TerminalFontRow() { |
| 1479 | const settings = usePrimarySettings(); | 1477 | const settings = useScopedSettings(); |
| 1480 | const updateSettings = useUpdatePrimarySettings(); | 1478 | const updateSettings = useUpdateScopedSettings(); |
| 1481 | const defaults = useFontDefaultFamilies(); | 1479 | const defaults = useFontDefaultFamilies(); |
| 1482 | return ( | 1480 | return ( |
| 1483 | <FontFamilySettingsRow | 1481 | <FontFamilySettingsRow |
| ... | @@ -1517,8 +1515,8 @@ function TerminalFontRow() { | ... | @@ -1517,8 +1515,8 @@ function TerminalFontRow() { |
| 1517 | } | 1515 | } |
| 1518 | 1516 | ||
| 1519 | function FontSmoothingRow() { | 1517 | function FontSmoothingRow() { |
| 1520 | const settings = usePrimarySettings(); | 1518 | const settings = useScopedSettings(); |
| 1521 | const updateSettings = useUpdatePrimarySettings(); | 1519 | const updateSettings = useUpdateScopedSettings(); |
| 1522 | if (!isMacPlatform(navigator.platform)) return null; | 1520 | if (!isMacPlatform(navigator.platform)) return null; |
| 1523 | return ( | 1521 | return ( |
| 1524 | <SettingsRow | 1522 | <SettingsRow |
| ... | @@ -1546,8 +1544,8 @@ function FontSmoothingRow() { | ... | @@ -1546,8 +1544,8 @@ function FontSmoothingRow() { |
| 1546 | } | 1544 | } |
| 1547 | 1545 | ||
| 1548 | function WordWrapRow() { | 1546 | function WordWrapRow() { |
| 1549 | const settings = usePrimarySettings(); | 1547 | const settings = useScopedSettings(); |
| 1550 | const updateSettings = useUpdatePrimarySettings(); | 1548 | const updateSettings = useUpdateScopedSettings(); |
| 1551 | return ( | 1549 | return ( |
| 1552 | <SettingsRow | 1550 | <SettingsRow |
| 1553 | {...searchableSetting("word-wrap")} | 1551 | {...searchableSetting("word-wrap")} |
| ... | @@ -1589,7 +1587,7 @@ function FontSettingsGroup() { | ... | @@ -1589,7 +1587,7 @@ function FontSettingsGroup() { |
| 1589 | * under each row show every surface the choice reaches. | 1587 | * under each row show every surface the choice reaches. |
| 1590 | */ | 1588 | */ |
| 1591 | function SimpleFontRows() { | 1589 | function SimpleFontRows() { |
| 1592 | const settings = usePrimarySettings(); | 1590 | const settings = useScopedSettings(); |
| 1593 | return ( | 1591 | return ( |
| 1594 | <> | 1592 | <> |
| 1595 | <InterfaceFontRow preview={<PromptFontPreview />} /> | 1593 | <InterfaceFontRow preview={<PromptFontPreview />} /> |
| ... | @@ -1930,8 +1928,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([ | ... | @@ -1930,8 +1928,8 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([ |
| 1930 | * jump to one of the rows unfolds the section. | 1928 | * jump to one of the rows unfolds the section. |
| 1931 | */ | 1929 | */ |
| 1932 | function LegacyFeaturesSection() { | 1930 | function LegacyFeaturesSection() { |
| 1933 | const settings = usePrimarySettings(); | 1931 | const settings = useScopedSettings(); |
| 1934 | const updateSettings = useUpdatePrimarySettings(); | 1932 | const updateSettings = useUpdateScopedSettings(); |
| 1935 | const [open, setOpen] = useState(false); | 1933 | const [open, setOpen] = useState(false); |
| 1936 | const searchTargetId = useSettingsSearchTargetId(); | 1934 | const searchTargetId = useSettingsSearchTargetId(); |
| 1937 | const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features"); | 1935 | const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features"); |
| ... | @@ -1969,29 +1967,7 @@ function LegacyFeaturesSection() { | ... | @@ -1969,29 +1967,7 @@ function LegacyFeaturesSection() { |
| 1969 | <Switch | 1967 | <Switch |
| 1970 | checked={settings.planModeEnabled} | 1968 | checked={settings.planModeEnabled} |
| 1971 | onCheckedChange={(checked) => { | 1969 | onCheckedChange={(checked) => { |
| 1972 | const planModeEnabled = Boolean(checked); | 1970 | updateSettings({ planModeEnabled: Boolean(checked) }); |
| 1973 | const textGenerationModelSelection = withoutPlanAgentSelection( | ||
| 1974 | settings.textGenerationModelSelection, | ||
| 1975 | ); | ||
| 1976 | const sourceControlWriterModelSelection = withoutPlanAgentSelection( | ||
| 1977 | settings.sourceControlWriterModelSelection, | ||
| 1978 | ); | ||
| 1979 | updateSettings({ | ||
| 1980 | planModeEnabled, | ||
| 1981 | ...(planModeEnabled | ||
| 1982 | ? {} | ||
| 1983 | : { | ||
| 1984 | ...(textGenerationModelSelection && | ||
| 1985 | textGenerationModelSelection !== settings.textGenerationModelSelection | ||
| 1986 | ? { textGenerationModelSelection } | ||
| 1987 | : {}), | ||
| 1988 | ...(sourceControlWriterModelSelection && | ||
| 1989 | sourceControlWriterModelSelection !== | ||
| 1990 | settings.sourceControlWriterModelSelection | ||
| 1991 | ? { sourceControlWriterModelSelection } | ||
| 1992 | : {}), | ||
| 1993 | }), | ||
| 1994 | }); | ||
| 1995 | }} | 1971 | }} |
| 1996 | aria-label="Plan mode (legacy)" | 1972 | aria-label="Plan mode (legacy)" |
| 1997 | /> | 1973 | /> |
| ... | @@ -2012,10 +1988,12 @@ function LegacyFeaturesSection() { | ... | @@ -2012,10 +1988,12 @@ function LegacyFeaturesSection() { |
| 2012 | /> | 1988 | /> |
| 2013 | <SettingsRow | 1989 | <SettingsRow |
| 2014 | serverScoped | 1990 | serverScoped |
| 1991 | settingKeys={["enableLegacyTokenStreaming"]} | ||
| 2015 | {...searchableSetting("legacy-token-streaming")} | 1992 | {...searchableSetting("legacy-token-streaming")} |
| 2016 | description="Stream output token by token. This legacy mode is slower and harder to follow." | 1993 | description="Stream output token by token. This legacy mode is slower and harder to follow." |
| 2017 | control={ | 1994 | control={ |
| 2018 | <Switch | 1995 | <ScopedSwitch |
| 1996 | settingKeys={["enableLegacyTokenStreaming"]} | ||
| 2019 | checked={settings.enableLegacyTokenStreaming} | 1997 | checked={settings.enableLegacyTokenStreaming} |
| 2020 | onCheckedChange={(checked) => { | 1998 | onCheckedChange={(checked) => { |
| 2021 | if (!checked) { | 1999 | if (!checked) { |
| ... | @@ -2058,25 +2036,32 @@ function LegacyFeaturesSection() { | ... | @@ -2058,25 +2036,32 @@ function LegacyFeaturesSection() { |
| 2058 | } | 2036 | } |
| 2059 | 2037 | ||
| 2060 | export function GeneralSettingsPanel() { | 2038 | export function GeneralSettingsPanel() { |
| 2061 | const settings = usePrimarySettings(); | 2039 | const settings = useScopedSettings(); |
| 2062 | const updateSettings = useUpdatePrimarySettings(); | 2040 | const updateSettings = useUpdateScopedSettings(); |
| 2063 | const navigate = useNavigate(); | 2041 | const navigate = useNavigate(); |
| 2064 | const environmentId = usePrimaryEnvironmentId(); | 2042 | const { scope, environment, connectedEnvironments } = useSettingsScope(); |
| 2043 | // The representative environment supplies the provider list for pickers; | ||
| 2044 | // a fanned-out model choice is validated against every target before it | ||
| 2045 | // is written. Per-machine tuning (background activity overrides) still | ||
| 2046 | // needs exactly one environment. | ||
| 2047 | const environmentId = environment?.environmentId ?? null; | ||
| 2048 | const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null; | ||
| 2049 | const hasServerTargets = connectedEnvironments.length > 0; | ||
| 2065 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); | 2050 | const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); |
| 2066 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( | 2051 | const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>( |
| 2067 | readLastEnabledProjectGroupingMode(), | 2052 | readLastEnabledProjectGroupingMode(), |
| 2068 | ); | 2053 | ); |
| 2069 | const observability = useAtomValue(primaryServerObservabilityAtom); | 2054 | const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; |
| 2070 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | ||
| 2071 | const supportsAutoSettlement = | 2055 | const supportsAutoSettlement = |
| 2072 | useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; | 2056 | connectedEnvironments.length > 0 && |
| 2073 | const diagnosticsDescription = formatDiagnosticsDescription({ | 2057 | connectedEnvironments.every( |
| 2074 | localTracingEnabled: observability?.localTracingEnabled ?? false, | 2058 | (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, |
| 2075 | otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, | 2059 | ); |
| 2076 | otlpTracesUrl: observability?.otlpTracesUrl, | 2060 | const supportsRestartContinuation = |
| 2077 | otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, | 2061 | connectedEnvironments.length > 0 && |
| 2078 | otlpMetricsUrl: observability?.otlpMetricsUrl, | 2062 | connectedEnvironments.every( |
| 2079 | }); | 2063 | (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, |
| 2064 | ); | ||
| 2080 | 2065 | ||
| 2081 | const textGenerationProviders = serverProviders.filter( | 2066 | const textGenerationProviders = serverProviders.filter( |
| 2082 | (provider) => provider.supportsTextGeneration !== false, | 2067 | (provider) => provider.supportsTextGeneration !== false, |
| ... | @@ -2109,9 +2094,16 @@ export function GeneralSettingsPanel() { | ... | @@ -2109,9 +2094,16 @@ export function GeneralSettingsPanel() { |
| 2109 | settings.textGenerationModelSelection ?? null, | 2094 | settings.textGenerationModelSelection ?? null, |
| 2110 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, | 2095 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, |
| 2111 | ); | 2096 | ); |
| 2097 | const textGenerationModelDisabledReason = useScopedModelDisabledReason( | ||
| 2098 | settings, | ||
| 2099 | textGenerationModelInstanceEntries, | ||
| 2100 | ); | ||
| 2112 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | 2101 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| 2113 | const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; | 2102 | const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; |
| 2114 | const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); | 2103 | const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); |
| 2104 | const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); | ||
| 2105 | const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); | ||
| 2106 | const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); | ||
| 2115 | const backgroundActivityDescription = | 2107 | const backgroundActivityDescription = |
| 2116 | backgroundActivityProfileOption === "advanced" | 2108 | backgroundActivityProfileOption === "advanced" |
| 2117 | ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ | 2109 | ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ |
| ... | @@ -2125,7 +2117,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2125,7 +2117,7 @@ export function GeneralSettingsPanel() { |
| 2125 | 2117 | ||
| 2126 | return ( | 2118 | return ( |
| 2127 | <SettingsPageContainer> | 2119 | <SettingsPageContainer> |
| 2128 | <SharedSettingsMismatchAlert /> | 2120 | <ProjectDefaultsSettings category="general" /> |
| 2129 | <SettingsSection id="organization" title="Organization"> | 2121 | <SettingsSection id="organization" title="Organization"> |
| 2130 | <SettingsRow | 2122 | <SettingsRow |
| 2131 | {...searchableSetting("project-grouping")} | 2123 | {...searchableSetting("project-grouping")} |
| ... | @@ -2167,6 +2159,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2167,6 +2159,7 @@ export function GeneralSettingsPanel() { |
| 2167 | <> | 2159 | <> |
| 2168 | <SettingsRow | 2160 | <SettingsRow |
| 2169 | serverScoped | 2161 | serverScoped |
| 2162 | settingKeys={["sidebarAutoSettleOnMerge"]} | ||
| 2170 | {...searchableSetting("auto-settle-merged-threads")} | 2163 | {...searchableSetting("auto-settle-merged-threads")} |
| 2171 | description="Settle a thread when its pull request merges. Closed pull requests still settle automatically." | 2164 | description="Settle a thread when its pull request merges. Closed pull requests still settle automatically." |
| 2172 | resetAction={ | 2165 | resetAction={ |
| ... | @@ -2183,7 +2176,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2183,7 +2176,8 @@ export function GeneralSettingsPanel() { |
| 2183 | ) : null | 2176 | ) : null |
| 2184 | } | 2177 | } |
| 2185 | control={ | 2178 | control={ |
| 2186 | <Switch | 2179 | <ScopedSwitch |
| 2180 | settingKeys={["sidebarAutoSettleOnMerge"]} | ||
| 2187 | checked={settings.sidebarAutoSettleOnMerge} | 2181 | checked={settings.sidebarAutoSettleOnMerge} |
| 2188 | onCheckedChange={(checked) => | 2182 | onCheckedChange={(checked) => |
| 2189 | updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) | 2183 | updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) |
| ... | @@ -2195,6 +2189,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2195,6 +2189,7 @@ export function GeneralSettingsPanel() { |
| 2195 | 2189 | ||
| 2196 | <SettingsRow | 2190 | <SettingsRow |
| 2197 | serverScoped | 2191 | serverScoped |
| 2192 | settingKeys={["sidebarAutoSettleAfterDays"]} | ||
| 2198 | {...searchableSetting("auto-settle-inactive-threads")} | 2193 | {...searchableSetting("auto-settle-inactive-threads")} |
| 2199 | description="Sidebar threads with no activity for this long settle automatically." | 2194 | description="Sidebar threads with no activity for this long settle automatically." |
| 2200 | resetAction={ | 2195 | resetAction={ |
| ... | @@ -2212,7 +2207,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2212,7 +2207,8 @@ export function GeneralSettingsPanel() { |
| 2212 | ) : null | 2207 | ) : null |
| 2213 | } | 2208 | } |
| 2214 | control={ | 2209 | control={ |
| 2215 | <Switch | 2210 | <ScopedSwitch |
| 2211 | settingKeys={["sidebarAutoSettleAfterDays"]} | ||
| 2216 | checked={settings.sidebarAutoSettleAfterDays !== null} | 2212 | checked={settings.sidebarAutoSettleAfterDays !== null} |
| 2217 | onCheckedChange={(checked) => | 2213 | onCheckedChange={(checked) => |
| 2218 | updateSettings({ | 2214 | updateSettings({ |
| ... | @@ -2226,6 +2222,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2226,6 +2222,7 @@ export function GeneralSettingsPanel() { |
| 2226 | {settings.sidebarAutoSettleAfterDays !== null ? ( | 2222 | {settings.sidebarAutoSettleAfterDays !== null ? ( |
| 2227 | <SettingsRow | 2223 | <SettingsRow |
| 2228 | serverScoped | 2224 | serverScoped |
| 2225 | settingKeys={["sidebarAutoSettleAfterDays"]} | ||
| 2229 | title={searchableSetting("days-before-auto-settle").title} | 2226 | title={searchableSetting("days-before-auto-settle").title} |
| 2230 | description="Any new activity un-settles a thread automatically." | 2227 | description="Any new activity un-settles a thread automatically." |
| 2231 | control={ | 2228 | control={ |
| ... | @@ -2423,6 +2420,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2423,6 +2420,7 @@ export function GeneralSettingsPanel() { |
| 2423 | 2420 | ||
| 2424 | <SettingsRow | 2421 | <SettingsRow |
| 2425 | serverScoped | 2422 | serverScoped |
| 2423 | settingKeys={["enableProviderUpdateChecks"]} | ||
| 2426 | {...searchableSetting("provider-update-checks")} | 2424 | {...searchableSetting("provider-update-checks")} |
| 2427 | description="Check installed provider CLIs for newer available versions." | 2425 | description="Check installed provider CLIs for newer available versions." |
| 2428 | resetAction={ | 2426 | resetAction={ |
| ... | @@ -2439,7 +2437,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2439,7 +2437,8 @@ export function GeneralSettingsPanel() { |
| 2439 | ) : null | 2437 | ) : null |
| 2440 | } | 2438 | } |
| 2441 | control={ | 2439 | control={ |
| 2442 | <Switch | 2440 | <ScopedSwitch |
| 2441 | settingKeys={["enableProviderUpdateChecks"]} | ||
| 2443 | checked={settings.enableProviderUpdateChecks} | 2442 | checked={settings.enableProviderUpdateChecks} |
| 2444 | onCheckedChange={(checked) => | 2443 | onCheckedChange={(checked) => |
| 2445 | updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) | 2444 | updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) |
| ... | @@ -2452,10 +2451,17 @@ export function GeneralSettingsPanel() { | ... | @@ -2452,10 +2451,17 @@ export function GeneralSettingsPanel() { |
| 2452 | <SettingsRow | 2451 | <SettingsRow |
| 2453 | {...searchableSetting("continue-threads-after-server-update")} | 2452 | {...searchableSetting("continue-threads-after-server-update")} |
| 2454 | serverScoped | 2453 | serverScoped |
| 2455 | description="Automatically resume interrupted threads after an update, crash, or machine restart. Applies to this environment and all connected environments that support it. Update older servers first." | 2454 | settingKeys={["continueThreadsAfterServerUpdate"]} |
| 2455 | description="Automatically resume interrupted threads after an update, crash, or machine restart on the selected environments. Update older servers first." | ||
| 2456 | status={ | ||
| 2457 | !supportsRestartContinuation | ||
| 2458 | ? "All selected connected environments must support restart continuation." | ||
| 2459 | : undefined | ||
| 2460 | } | ||
| 2456 | resetAction={ | 2461 | resetAction={ |
| 2462 | supportsRestartContinuation && | ||
| 2457 | settings.continueThreadsAfterServerUpdate !== | 2463 | settings.continueThreadsAfterServerUpdate !== |
| 2458 | DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? ( | 2464 | DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? ( |
| 2459 | <SettingResetButton | 2465 | <SettingResetButton |
| 2460 | label="continue threads after restarts" | 2466 | label="continue threads after restarts" |
| 2461 | onClick={() => | 2467 | onClick={() => |
| ... | @@ -2468,8 +2474,10 @@ export function GeneralSettingsPanel() { | ... | @@ -2468,8 +2474,10 @@ export function GeneralSettingsPanel() { |
| 2468 | ) : null | 2474 | ) : null |
| 2469 | } | 2475 | } |
| 2470 | control={ | 2476 | control={ |
| 2471 | <Switch | 2477 | <ScopedSwitch |
| 2478 | settingKeys={["continueThreadsAfterServerUpdate"]} | ||
| 2472 | checked={settings.continueThreadsAfterServerUpdate} | 2479 | checked={settings.continueThreadsAfterServerUpdate} |
| 2480 | disabled={!supportsRestartContinuation} | ||
| 2473 | onCheckedChange={(checked) => | 2481 | onCheckedChange={(checked) => |
| 2474 | updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) }) | 2482 | updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) }) |
| 2475 | } | 2483 | } |
| ... | @@ -2480,6 +2488,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2480,6 +2488,7 @@ export function GeneralSettingsPanel() { |
| 2480 | 2488 | ||
| 2481 | <SettingsRow | 2489 | <SettingsRow |
| 2482 | serverScoped | 2490 | serverScoped |
| 2491 | settingKeys={["backgroundActivity"]} | ||
| 2483 | id={searchableSetting("background-activity").id} | 2492 | id={searchableSetting("background-activity").id} |
| 2484 | title={ | 2493 | title={ |
| 2485 | <span className="inline-flex items-center gap-1.5"> | 2494 | <span className="inline-flex items-center gap-1.5"> |
| ... | @@ -2502,10 +2511,10 @@ export function GeneralSettingsPanel() { | ... | @@ -2502,10 +2511,10 @@ export function GeneralSettingsPanel() { |
| 2502 | control={ | 2511 | control={ |
| 2503 | <> | 2512 | <> |
| 2504 | <Select | 2513 | <Select |
| 2505 | value={backgroundActivityProfileOption} | 2514 | value={mixedBackgroundActivity ? null : backgroundActivityProfileOption} |
| 2506 | onValueChange={(value) => { | 2515 | onValueChange={(value) => { |
| 2507 | if (value === "advanced") { | 2516 | if (value === "advanced") { |
| 2508 | setBackgroundActivityDialogOpen(true); | 2517 | if (isEnvironmentScope) setBackgroundActivityDialogOpen(true); |
| 2509 | return; | 2518 | return; |
| 2510 | } | 2519 | } |
| 2511 | if ( | 2520 | if ( |
| ... | @@ -2523,7 +2532,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2523,7 +2532,9 @@ export function GeneralSettingsPanel() { |
| 2523 | aria-label="Background activity profile" | 2532 | aria-label="Background activity profile" |
| 2524 | > | 2533 | > |
| 2525 | <SelectValue> | 2534 | <SelectValue> |
| 2526 | {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]} | 2535 | {(value: BackgroundActivityProfileOption | null) => |
| 2536 | value === null ? "Mixed" : BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[value] | ||
| 2537 | } | ||
| 2527 | </SelectValue> | 2538 | </SelectValue> |
| 2528 | </SelectTrigger> | 2539 | </SelectTrigger> |
| 2529 | <SelectPopup align="end" alignItemWithTrigger={false}> | 2540 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| ... | @@ -2536,12 +2547,14 @@ export function GeneralSettingsPanel() { | ... | @@ -2536,12 +2547,14 @@ export function GeneralSettingsPanel() { |
| 2536 | <SelectItem hideIndicator value="battery-saver"> | 2547 | <SelectItem hideIndicator value="battery-saver"> |
| 2537 | {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]} | 2548 | {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]} |
| 2538 | </SelectItem> | 2549 | </SelectItem> |
| 2539 | <SelectItem hideIndicator value="advanced"> | 2550 | <SelectItem hideIndicator value="advanced" disabled={!isEnvironmentScope}> |
| 2540 | {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} | 2551 | {isEnvironmentScope |
| 2552 | ? BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced | ||
| 2553 | : `${BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} (one environment)`} | ||
| 2541 | </SelectItem> | 2554 | </SelectItem> |
| 2542 | </SelectPopup> | 2555 | </SelectPopup> |
| 2543 | </Select> | 2556 | </Select> |
| 2544 | {backgroundActivityProfileOption === "advanced" ? ( | 2557 | {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? ( |
| 2545 | <Tooltip> | 2558 | <Tooltip> |
| 2546 | <TooltipTrigger | 2559 | <TooltipTrigger |
| 2547 | render={ | 2560 | render={ |
| ... | @@ -2559,7 +2572,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2559,7 +2572,7 @@ export function GeneralSettingsPanel() { |
| 2559 | </Tooltip> | 2572 | </Tooltip> |
| 2560 | ) : null} | 2573 | ) : null} |
| 2561 | <BackgroundActivityAdvancedDialog | 2574 | <BackgroundActivityAdvancedDialog |
| 2562 | open={backgroundActivityDialogOpen} | 2575 | open={backgroundActivityDialogOpen && isEnvironmentScope} |
| 2563 | onOpenChange={setBackgroundActivityDialogOpen} | 2576 | onOpenChange={setBackgroundActivityDialogOpen} |
| 2564 | /> | 2577 | /> |
| 2565 | </> | 2578 | </> |
| ... | @@ -2568,24 +2581,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2568,24 +2581,9 @@ export function GeneralSettingsPanel() { |
| 2568 | </SettingsSection> | 2581 | </SettingsSection> |
| 2569 | 2582 | ||
| 2570 | <SettingsSection id="projects-and-threads" title="Projects & threads"> | 2583 | <SettingsSection id="projects-and-threads" title="Projects & threads"> |
| 2571 | <SettingsRow | ||
| 2572 | {...searchableSetting("new-threads")} | ||
| 2573 | description="Choose the default model and workspace for all projects or a specific project." | ||
| 2574 | control={ | ||
| 2575 | <Button | ||
| 2576 | render={ | ||
| 2577 | <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} /> | ||
| 2578 | } | ||
| 2579 | size="sm" | ||
| 2580 | variant="outline" | ||
| 2581 | > | ||
| 2582 | Project settings | ||
| 2583 | </Button> | ||
| 2584 | } | ||
| 2585 | /> | ||
| 2586 | |||
| 2587 | <SettingsRow | 2584 | <SettingsRow |
| 2588 | serverScoped | 2585 | serverScoped |
| 2586 | settingKeys={["newWorktreesStartFromOrigin"]} | ||
| 2589 | {...searchableSetting("start-from-origin")} | 2587 | {...searchableSetting("start-from-origin")} |
| 2590 | description="Creates the worktree from the latest matching branch on origin instead of your local branch." | 2588 | description="Creates the worktree from the latest matching branch on origin instead of your local branch." |
| 2591 | resetAction={ | 2589 | resetAction={ |
| ... | @@ -2603,7 +2601,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2603,7 +2601,8 @@ export function GeneralSettingsPanel() { |
| 2603 | ) : null | 2601 | ) : null |
| 2604 | } | 2602 | } |
| 2605 | control={ | 2603 | control={ |
| 2606 | <Switch | 2604 | <ScopedSwitch |
| 2605 | settingKeys={["newWorktreesStartFromOrigin"]} | ||
| 2607 | checked={settings.newWorktreesStartFromOrigin} | 2606 | checked={settings.newWorktreesStartFromOrigin} |
| 2608 | onCheckedChange={(checked) => | 2607 | onCheckedChange={(checked) => |
| 2609 | updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) | 2608 | updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) |
| ... | @@ -2614,6 +2613,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2614,6 +2613,7 @@ export function GeneralSettingsPanel() { |
| 2614 | /> | 2613 | /> |
| 2615 | <SettingsRow | 2614 | <SettingsRow |
| 2616 | serverScoped | 2615 | serverScoped |
| 2616 | settingKeys={["addProjectBaseDirectory"]} | ||
| 2617 | {...searchableSetting("add-project-starts-in")} | 2617 | {...searchableSetting("add-project-starts-in")} |
| 2618 | description='Leave empty to use "~/" when the Add Project browser opens.' | 2618 | description='Leave empty to use "~/" when the Add Project browser opens.' |
| 2619 | resetAction={ | 2619 | resetAction={ |
| ... | @@ -2633,9 +2633,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2633,9 +2633,9 @@ export function GeneralSettingsPanel() { |
| 2633 | <DraftInput | 2633 | <DraftInput |
| 2634 | size="sm" | 2634 | size="sm" |
| 2635 | className="w-full sm:w-72" | 2635 | className="w-full sm:w-72" |
| 2636 | value={settings.addProjectBaseDirectory} | 2636 | value={mixedAddProjectBaseDirectory ? "" : settings.addProjectBaseDirectory} |
| 2637 | onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })} | 2637 | onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })} |
| 2638 | placeholder="~/" | 2638 | placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"} |
| 2639 | spellCheck={false} | 2639 | spellCheck={false} |
| 2640 | aria-label="Add project base directory" | 2640 | aria-label="Add project base directory" |
| 2641 | /> | 2641 | /> |
| ... | @@ -2768,10 +2768,11 @@ export function GeneralSettingsPanel() { | ... | @@ -2768,10 +2768,11 @@ export function GeneralSettingsPanel() { |
| 2768 | <SettingsSection id="text-generation" title="Text generation"> | 2768 | <SettingsSection id="text-generation" title="Text generation"> |
| 2769 | <SettingsRow | 2769 | <SettingsRow |
| 2770 | serverScoped | 2770 | serverScoped |
| 2771 | settingKeys={["textGenerationModelSelection"]} | ||
| 2771 | {...searchableSetting("text-generation-model")} | 2772 | {...searchableSetting("text-generation-model")} |
| 2772 | description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it." | 2773 | description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it." |
| 2773 | resetAction={ | 2774 | resetAction={ |
| 2774 | isTextGenerationModelDirty ? ( | 2775 | hasServerTargets && isTextGenerationModelDirty ? ( |
| 2775 | <SettingResetButton | 2776 | <SettingResetButton |
| 2776 | label="text generation model" | 2777 | label="text generation model" |
| 2777 | onClick={() => | 2778 | onClick={() => |
| ... | @@ -2784,7 +2785,11 @@ export function GeneralSettingsPanel() { | ... | @@ -2784,7 +2785,11 @@ export function GeneralSettingsPanel() { |
| 2784 | ) : null | 2785 | ) : null |
| 2785 | } | 2786 | } |
| 2786 | control={ | 2787 | control={ |
| 2787 | !hasTextGenerationProvider ? ( | 2788 | !hasServerTargets ? ( |
| 2789 | <span className="text-sm text-muted-foreground"> | ||
| 2790 | Connect an environment to choose its text generation model. | ||
| 2791 | </span> | ||
| 2792 | ) : !hasTextGenerationProvider ? ( | ||
| 2788 | <span className="text-sm text-muted-foreground"> | 2793 | <span className="text-sm text-muted-foreground"> |
| 2789 | No text generation providers available. | 2794 | No text generation providers available. |
| 2790 | </span> | 2795 | </span> |
| ... | @@ -2798,6 +2803,8 @@ export function GeneralSettingsPanel() { | ... | @@ -2798,6 +2803,8 @@ export function GeneralSettingsPanel() { |
| 2798 | modelOptionsByInstance={textGenerationModelOptionsByInstance} | 2803 | modelOptionsByInstance={textGenerationModelOptionsByInstance} |
| 2799 | triggerVariant="outline" | 2804 | triggerVariant="outline" |
| 2800 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | 2805 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} |
| 2806 | {...(mixedTextGenerationModel ? { triggerLabel: "Mixed" } : {})} | ||
| 2807 | getModelDisabledReason={textGenerationModelDisabledReason} | ||
| 2801 | {...(environmentId | 2808 | {...(environmentId |
| 2802 | ? { | 2809 | ? { |
| 2803 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => { | 2810 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => { |
| ... | @@ -2809,6 +2816,15 @@ export function GeneralSettingsPanel() { | ... | @@ -2809,6 +2816,15 @@ export function GeneralSettingsPanel() { |
| 2809 | } | 2816 | } |
| 2810 | : {})} | 2817 | : {})} |
| 2811 | onInstanceModelChange={(instanceId, model) => { | 2818 | onInstanceModelChange={(instanceId, model) => { |
| 2819 | const reason = textGenerationModelDisabledReason(instanceId, model); | ||
| 2820 | if (reason) { | ||
| 2821 | toastManager.add({ | ||
| 2822 | type: "error", | ||
| 2823 | title: "Text generation model not saved", | ||
| 2824 | description: reason, | ||
| 2825 | }); | ||
| 2826 | return; | ||
| 2827 | } | ||
| 2812 | updateSettings({ | 2828 | updateSettings({ |
| 2813 | textGenerationModelSelection: resolveAppModelSelectionState( | 2829 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 2814 | { | 2830 | { |
| ... | @@ -2870,11 +2886,23 @@ export function GeneralSettingsPanel() { | ... | @@ -2870,11 +2886,23 @@ export function GeneralSettingsPanel() { |
| 2870 | description="Current version of the application." | 2886 | description="Current version of the application." |
| 2871 | /> | 2887 | /> |
| 2872 | )} | 2888 | )} |
| 2889 | </SettingsSection> | ||
| 2890 | <SettingsSection title="Diagnostics"> | ||
| 2873 | <SettingsRow | 2891 | <SettingsRow |
| 2874 | {...searchableSetting("diagnostics")} | 2892 | {...searchableSetting("diagnostics")} |
| 2875 | description={diagnosticsDescription} | 2893 | description={ |
| 2894 | isEnvironmentScope | ||
| 2895 | ? "Inspect processes, resource use, and logs on this environment." | ||
| 2896 | : "Inspect processes, resource use, and logs on one environment at a time." | ||
| 2897 | } | ||
| 2876 | control={ | 2898 | control={ |
| 2877 | <Button render={<Link to="/settings/diagnostics" />} size="sm" variant="outline"> | 2899 | <Button |
| 2900 | render={ | ||
| 2901 | <Link to="/settings/diagnostics" search={{ machine: environmentId ?? undefined }} /> | ||
| 2902 | } | ||
| 2903 | size="sm" | ||
| 2904 | variant="outline" | ||
| 2905 | > | ||
| 2878 | View diagnostics | 2906 | View diagnostics |
| 2879 | </Button> | 2907 | </Button> |
| 2880 | } | 2908 | } |
| ... | @@ -2887,25 +2915,31 @@ export function GeneralSettingsPanel() { | ... | @@ -2887,25 +2915,31 @@ export function GeneralSettingsPanel() { |
| 2887 | } | 2915 | } |
| 2888 | 2916 | ||
| 2889 | export function ArchivedThreadsPanel() { | 2917 | export function ArchivedThreadsPanel() { |
| 2890 | const projects = useProjects(); | 2918 | const { scope } = useSettingsScope(); |
| 2891 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 2919 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 2892 | const environmentIds = useMemo( | ||
| 2893 | () => [...new Set(projects.map((project) => project.environmentId))], | ||
| 2894 | [projects], | ||
| 2895 | ); | ||
| 2896 | const { | 2920 | const { |
| 2897 | snapshots: archivedSnapshots, | 2921 | snapshots: archivedSnapshots, |
| 2898 | error: archiveError, | 2922 | error: archiveError, |
| 2899 | isLoading: isLoadingArchive, | 2923 | isLoading: isLoadingArchive, |
| 2900 | refresh: refreshArchivedThreads, | 2924 | refresh: refreshArchivedThreads, |
| 2901 | } = useArchivedThreadSnapshots(environmentIds); | 2925 | } = useArchivedThreadSnapshots(scope.environmentIds); |
| 2902 | 2926 | ||
| 2903 | const archivedGroups = useMemo(() => { | 2927 | const archivedGroups = useMemo(() => { |
| 2928 | const selectedProjectKeys = | ||
| 2929 | scope.kind === "project" || scope.kind === "checkout" | ||
| 2930 | ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`)) | ||
| 2931 | : null; | ||
| 2904 | const projectsByEnvironmentAndId = new Map( | 2932 | const projectsByEnvironmentAndId = new Map( |
| 2905 | archivedSnapshots.flatMap(({ environmentId, snapshot }) => | 2933 | archivedSnapshots.flatMap(({ environmentId, snapshot }) => |
| 2906 | snapshot.projects.map( | 2934 | snapshot.projects |
| 2907 | (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const, | 2935 | .filter( |
| 2908 | ), | 2936 | (project) => |
| 2937 | selectedProjectKeys === null || | ||
| 2938 | selectedProjectKeys.has(`${environmentId}:${project.id}`), | ||
| 2939 | ) | ||
| 2940 | .map( | ||
| 2941 | (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const, | ||
| 2942 | ), | ||
| 2909 | ), | 2943 | ), |
| 2910 | ); | 2944 | ); |
| 2911 | const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => | 2945 | const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => |
| ... | @@ -2939,7 +2973,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -2939,7 +2973,7 @@ export function ArchivedThreadsPanel() { |
| 2939 | } | 2973 | } |
| 2940 | } | 2974 | } |
| 2941 | return groups; | 2975 | return groups; |
| 2942 | }, [archivedSnapshots]); | 2976 | }, [archivedSnapshots, scope]); |
| 2943 | 2977 | ||
| 2944 | const handleArchivedThreadContextMenu = useCallback( | 2978 | const handleArchivedThreadContextMenu = useCallback( |
| 2945 | async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { | 2979 | async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { |
| ... | @@ -3021,7 +3055,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -3021,7 +3055,7 @@ export function ArchivedThreadsPanel() { |
| 3021 | ) : ( | 3055 | ) : ( |
| 3022 | archivedGroups.map(({ project, threads: projectThreads }, index) => ( | 3056 | archivedGroups.map(({ project, threads: projectThreads }, index) => ( |
| 3023 | <SettingsSection | 3057 | <SettingsSection |
| 3024 | key={project.id} | 3058 | key={`${project.environmentId}:${project.id}`} |
| 3025 | id={index === 0 ? searchableSetting("archive").id : undefined} | 3059 | id={index === 0 ? searchableSetting("archive").id : undefined} |
| 3026 | title={project.title} | 3060 | title={project.title} |
| 3027 | icon={<ProjectFavicon project={project} />} | 3061 | icon={<ProjectFavicon project={project} />} |
apps/web/src/components/settings/SettingsScopeContext.tsx created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import { createContext, type ReactNode, useContext, useMemo } from "react"; | ||
| 2 | |||
| 3 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 4 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | ||
| 5 | import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; | ||
| 6 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | ||
| 7 | |||
| 8 | function useResolvedSettingsScope(search: SettingsScopeSearch) { | ||
| 9 | const groups = useSettingsProjectGroups(); | ||
| 10 | const { environments: availableEnvironments } = useEnvironments(); | ||
| 11 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 12 | return useMemo(() => { | ||
| 13 | const scope = resolveSettingsScope(search, groups, availableEnvironments); | ||
| 14 | const selected = selectScopedSettingsEnvironments( | ||
| 15 | scope, | ||
| 16 | availableEnvironments, | ||
| 17 | primaryEnvironmentId, | ||
| 18 | ); | ||
| 19 | const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments); | ||
| 20 | // The representative target supplies display values; project scopes | ||
| 21 | // prefer the member on the primary environment, like environments do. | ||
| 22 | const target = | ||
| 23 | targets.find( | ||
| 24 | (candidate) => candidate.environmentId === selected.environment?.environmentId, | ||
| 25 | ) ?? | ||
| 26 | targets[0] ?? | ||
| 27 | null; | ||
| 28 | return { scope, groups, ...selected, targets, target }; | ||
| 29 | }, [availableEnvironments, groups, primaryEnvironmentId, search]); | ||
| 30 | } | ||
| 31 | |||
| 32 | const SettingsScopeContext = createContext< | ||
| 33 | | (ReturnType<typeof useResolvedSettingsScope> & { | ||
| 34 | search: SettingsScopeSearch; | ||
| 35 | selectScope: (next: SettingsScopeSearch) => void; | ||
| 36 | }) | ||
| 37 | | null | ||
| 38 | >(null); | ||
| 39 | |||
| 40 | export function SettingsScopeProvider({ | ||
| 41 | search, | ||
| 42 | onChange, | ||
| 43 | children, | ||
| 44 | }: { | ||
| 45 | search: SettingsScopeSearch; | ||
| 46 | onChange: (next: SettingsScopeSearch) => void; | ||
| 47 | children: ReactNode; | ||
| 48 | }) { | ||
| 49 | const resolved = useResolvedSettingsScope(search); | ||
| 50 | const value = useMemo( | ||
| 51 | () => ({ ...resolved, search, selectScope: onChange }), | ||
| 52 | [onChange, resolved, search], | ||
| 53 | ); | ||
| 54 | return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function useOptionalSettingsScope() { | ||
| 58 | return useContext(SettingsScopeContext); | ||
| 59 | } | ||
| 60 | |||
| 61 | export function useSettingsScope() { | ||
| 62 | const scope = useOptionalSettingsScope(); | ||
| 63 | if (scope === null) throw new Error("Settings scope must be read inside SettingsScopeProvider."); | ||
| 64 | return scope; | ||
| 65 | } | ||
apps/web/src/components/settings/SettingsScopeNotice.tsx created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import { Button } from "../ui/button"; | ||
| 2 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | ||
| 3 | import { SettingsPageContainer } from "./settingsLayout"; | ||
| 4 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 5 | import { useEnvironments } from "../../state/environments"; | ||
| 6 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 7 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | ||
| 8 | import { useLocation, useNavigate } from "@tanstack/react-router"; | ||
| 9 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | /** Offer an explicit target change when a category has no settings at this scope. */ | ||
| 12 | export function SettingsScopeNotice({ | ||
| 13 | children, | ||
| 14 | target, | ||
| 15 | targetId, | ||
| 16 | eligibleEnvironmentIds, | ||
| 17 | }: { | ||
| 18 | children: string; | ||
| 19 | target: "environment" | "all" | "project" | "checkout"; | ||
| 20 | targetId?: string; | ||
| 21 | eligibleEnvironmentIds?: readonly EnvironmentId[]; | ||
| 22 | }) { | ||
| 23 | const { selectScope, search } = useSettingsScope(); | ||
| 24 | const navigate = useNavigate({ from: "/settings" }); | ||
| 25 | const pathname = useLocation({ select: (location) => location.pathname }); | ||
| 26 | const { environments } = useEnvironments(); | ||
| 27 | const groups = useSettingsProjectGroups(); | ||
| 28 | const choices: { label: string; search: SettingsScopeSearch }[] = | ||
| 29 | target === "checkout" | ||
| 30 | ? groups | ||
| 31 | .filter((group) => !search.project || group.projectKey === search.project) | ||
| 32 | .flatMap((group) => | ||
| 33 | group.memberProjects.map((member) => ({ | ||
| 34 | label: `${group.displayName} · ${member.environmentLabel ?? "Environment"} · ${member.workspaceRoot}`, | ||
| 35 | search: { | ||
| 36 | project: group.projectKey, | ||
| 37 | machine: member.environmentId, | ||
| 38 | checkout: member.physicalProjectKey, | ||
| 39 | }, | ||
| 40 | })), | ||
| 41 | ) | ||
| 42 | : target === "project" | ||
| 43 | ? groups.map((group) => ({ | ||
| 44 | label: group.displayName, | ||
| 45 | search: { project: group.projectKey }, | ||
| 46 | })) | ||
| 47 | : target === "environment" | ||
| 48 | ? environments | ||
| 49 | .filter( | ||
| 50 | (entry) => | ||
| 51 | eligibleEnvironmentIds === undefined || | ||
| 52 | eligibleEnvironmentIds.includes(entry.environmentId), | ||
| 53 | ) | ||
| 54 | .map((entry) => ({ | ||
| 55 | label: environments.some( | ||
| 56 | (other) => | ||
| 57 | other.environmentId !== entry.environmentId && other.label === entry.label, | ||
| 58 | ) | ||
| 59 | ? `${entry.label} · ${entry.displayUrl || entry.environmentId}` | ||
| 60 | : entry.label, | ||
| 61 | search: { machine: entry.environmentId }, | ||
| 62 | })) | ||
| 63 | : [{ label: "Open all environments", search: {} }]; | ||
| 64 | return ( | ||
| 65 | <SettingsPageContainer> | ||
| 66 | <Alert role="status"> | ||
| 67 | <AlertDescription> | ||
| 68 | <p>{children}</p> | ||
| 69 | <AlertAction className="flex-wrap gap-2"> | ||
| 70 | {choices.map((choice) => ( | ||
| 71 | <Button | ||
| 72 | key={JSON.stringify(choice.search)} | ||
| 73 | size="sm-multiline" | ||
| 74 | variant="outline" | ||
| 75 | className="max-w-full break-all text-left" | ||
| 76 | onClick={() => { | ||
| 77 | if (targetId) | ||
| 78 | void navigate({ to: pathname, search: () => choice.search, hash: targetId }); | ||
| 79 | else selectScope(choice.search); | ||
| 80 | }} | ||
| 81 | > | ||
| 82 | {choice.label} | ||
| 83 | </Button> | ||
| 84 | ))} | ||
| 85 | </AlertAction> | ||
| 86 | </AlertDescription> | ||
| 87 | </Alert> | ||
| 88 | </SettingsPageContainer> | ||
| 89 | ); | ||
| 90 | } | ||
apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts deleted-88| ... | @@ -1,88 +0,0 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | environmentAxisValue, | ||
| 6 | projectAxisValue, | ||
| 7 | selectEnvironmentAxis, | ||
| 8 | selectProjectAxis, | ||
| 9 | settingsScopeEnvironmentLabel, | ||
| 10 | } from "./SettingsScopeSelects.logic"; | ||
| 11 | |||
| 12 | const first = { | ||
| 13 | environmentId: EnvironmentId.make("first"), | ||
| 14 | label: "Development", | ||
| 15 | displayUrl: "https://first.example.com", | ||
| 16 | }; | ||
| 17 | const second = { | ||
| 18 | environmentId: EnvironmentId.make("second"), | ||
| 19 | label: "Development", | ||
| 20 | displayUrl: "https://second.example.com", | ||
| 21 | }; | ||
| 22 | |||
| 23 | describe("settings scope environment labels", () => { | ||
| 24 | it("distinguishes same-name environments by address", () => { | ||
| 25 | const environments = [first, second]; | ||
| 26 | expect( | ||
| 27 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 28 | ).toEqual([ | ||
| 29 | "Development · https://first.example.com", | ||
| 30 | "Development · https://second.example.com", | ||
| 31 | ]); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("falls back to environment IDs when duplicate names have no display URL", () => { | ||
| 35 | const environments = [first, second].map((environment) => ({ | ||
| 36 | ...environment, | ||
| 37 | displayUrl: null, | ||
| 38 | })); | ||
| 39 | expect( | ||
| 40 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 41 | ).toEqual(["Development · first", "Development · second"]); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("keeps unique names compact and removes disambiguation after a rename", () => { | ||
| 45 | expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); | ||
| 46 | expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( | ||
| 47 | "Development", | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | }); | ||
| 51 | |||
| 52 | describe("settings scope axes", () => { | ||
| 53 | it("maps each axis to its search key and back", () => { | ||
| 54 | expect(projectAxisValue({})).toBe("all"); | ||
| 55 | expect(projectAxisValue({ project: "app" })).toBe("app"); | ||
| 56 | expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({ | ||
| 57 | project: "app", | ||
| 58 | machine: "second", | ||
| 59 | }); | ||
| 60 | expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({ | ||
| 61 | machine: "second", | ||
| 62 | }); | ||
| 63 | expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({ | ||
| 64 | project: "app", | ||
| 65 | machine: "first", | ||
| 66 | }); | ||
| 67 | expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({ | ||
| 68 | project: "app", | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("drops a checkout narrowing from older links when either axis changes", () => { | ||
| 73 | const checkout = { project: "app", checkout: "app@first", machine: "first" }; | ||
| 74 | expect(selectEnvironmentAxis(checkout, "second")).toEqual({ | ||
| 75 | project: "app", | ||
| 76 | machine: "second", | ||
| 77 | }); | ||
| 78 | expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" }); | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | describe("environmentAxisValue", () => { | ||
| 83 | it("shows the checkout's environment for a legacy checkout link", () => { | ||
| 84 | expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop"); | ||
| 85 | expect(environmentAxisValue({ project: "p" }, null)).toBe("all"); | ||
| 86 | expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); | ||
| 87 | }); | ||
| 88 | }); | ||
apps/web/src/components/settings/SettingsScopeSelects.logic.ts deleted-55| ... | @@ -1,55 +0,0 @@ | ||
| 1 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 2 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 3 | |||
| 4 | type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">; | ||
| 5 | |||
| 6 | export function settingsScopeEnvironmentLabel( | ||
| 7 | environment: ScopeEnvironment, | ||
| 8 | environments: readonly ScopeEnvironment[], | ||
| 9 | ) { | ||
| 10 | const duplicate = environments.some( | ||
| 11 | (other) => | ||
| 12 | other.environmentId !== environment.environmentId && other.label === environment.label, | ||
| 13 | ); | ||
| 14 | return duplicate | ||
| 15 | ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}` | ||
| 16 | : environment.label; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const ALL_ENVIRONMENTS_VALUE = "all"; | ||
| 20 | export const ALL_PROJECTS_VALUE = "all"; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * The environment axis: `all` or an environment id. A legacy checkout link | ||
| 24 | * without `machine` still names one environment, which the resolver supplies. | ||
| 25 | */ | ||
| 26 | export function environmentAxisValue( | ||
| 27 | search: SettingsScopeSearch, | ||
| 28 | resolvedEnvironmentId?: string | null, | ||
| 29 | ): string { | ||
| 30 | return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** The project axis: `all` or a project key. */ | ||
| 34 | export function projectAxisValue(search: SettingsScopeSearch): string { | ||
| 35 | return search.project ?? ALL_PROJECTS_VALUE; | ||
| 36 | } | ||
| 37 | |||
| 38 | /** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */ | ||
| 39 | export function selectEnvironmentAxis( | ||
| 40 | search: SettingsScopeSearch, | ||
| 41 | value: string, | ||
| 42 | ): SettingsScopeSearch { | ||
| 43 | const next: SettingsScopeSearch = {}; | ||
| 44 | if (search.project) next.project = search.project; | ||
| 45 | if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value; | ||
| 46 | return next; | ||
| 47 | } | ||
| 48 | |||
| 49 | /** Choosing a project keeps the environment axis. */ | ||
| 50 | export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch { | ||
| 51 | const next: SettingsScopeSearch = {}; | ||
| 52 | if (value !== ALL_PROJECTS_VALUE) next.project = value; | ||
| 53 | if (search.machine) next.machine = search.machine; | ||
| 54 | return next; | ||
| 55 | } | ||
apps/web/src/components/settings/SettingsScopeSelects.tsx deleted-154| ... | @@ -1,154 +0,0 @@ | ||
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | import { LayersIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | ||
| 5 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 6 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 7 | import { ProjectFavicon } from "../ProjectFavicon"; | ||
| 8 | import { | ||
| 9 | Select, | ||
| 10 | SelectGroup, | ||
| 11 | SelectGroupLabel, | ||
| 12 | SelectItem, | ||
| 13 | SelectPopup, | ||
| 14 | SelectTrigger, | ||
| 15 | SelectValue, | ||
| 16 | } from "../ui/select"; | ||
| 17 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | ||
| 18 | import { | ||
| 19 | ALL_ENVIRONMENTS_VALUE, | ||
| 20 | ALL_PROJECTS_VALUE, | ||
| 21 | environmentAxisValue, | ||
| 22 | projectAxisValue, | ||
| 23 | selectEnvironmentAxis, | ||
| 24 | selectProjectAxis, | ||
| 25 | settingsScopeEnvironmentLabel, | ||
| 26 | } from "./SettingsScopeSelects.logic"; | ||
| 27 | |||
| 28 | const TRIGGER_CLASSNAME = "w-auto min-w-0 max-w-56 justify-start"; | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Two independent targets: which environments a change applies to, and | ||
| 32 | * which project it overrides. A project is the same project on every | ||
| 33 | * environment; the environment select alone decides where the override is | ||
| 34 | * written. Each axis defaults to "all". | ||
| 35 | */ | ||
| 36 | export function SettingsScopeSelects({ | ||
| 37 | value, | ||
| 38 | groups, | ||
| 39 | environments, | ||
| 40 | onChange, | ||
| 41 | }: { | ||
| 42 | value: SettingsScopeSearch; | ||
| 43 | groups: readonly SidebarProjectSnapshot[]; | ||
| 44 | environments: readonly EnvironmentPresentation[]; | ||
| 45 | onChange: (next: SettingsScopeSearch) => void; | ||
| 46 | }) { | ||
| 47 | const resolved = resolveSettingsScope(value, groups, environments); | ||
| 48 | const environmentValue = environmentAxisValue( | ||
| 49 | value, | ||
| 50 | resolved.kind === "checkout" ? resolved.environmentId : null, | ||
| 51 | ); | ||
| 52 | const projectValue = projectAxisValue(value); | ||
| 53 | const selectedEnvironment = environments.find( | ||
| 54 | (environment) => environment.environmentId === environmentValue, | ||
| 55 | ); | ||
| 56 | const selectedGroup = groups.find((group) => group.projectKey === value.project); | ||
| 57 | |||
| 58 | return ( | ||
| 59 | <div className="flex min-w-0 items-center gap-1.5" role="group" aria-label="Settings scope"> | ||
| 60 | <Select | ||
| 61 | value={environmentValue} | ||
| 62 | onValueChange={(next) => { | ||
| 63 | if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); | ||
| 64 | }} | ||
| 65 | > | ||
| 66 | <SelectTrigger size="compact" aria-label="Environment scope" className={TRIGGER_CLASSNAME}> | ||
| 67 | <SelectValue> | ||
| 68 | <span className="flex min-w-0 items-center gap-1.5"> | ||
| 69 | {selectedEnvironment ? ( | ||
| 70 | <EnvironmentMachineIcon | ||
| 71 | aria-hidden | ||
| 72 | kind={resolveEnvironmentMachineKind(selectedEnvironment.serverConfig)} | ||
| 73 | className="size-3.5" | ||
| 74 | /> | ||
| 75 | ) : ( | ||
| 76 | <LayersIcon aria-hidden className="size-3.5" /> | ||
| 77 | )} | ||
| 78 | <span className="truncate"> | ||
| 79 | {selectedEnvironment | ||
| 80 | ? settingsScopeEnvironmentLabel(selectedEnvironment, environments) | ||
| 81 | : environmentValue !== ALL_ENVIRONMENTS_VALUE | ||
| 82 | ? "Unavailable environment" | ||
| 83 | : "All environments"} | ||
| 84 | </span> | ||
| 85 | </span> | ||
| 86 | </SelectValue> | ||
| 87 | </SelectTrigger> | ||
| 88 | <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56"> | ||
| 89 | <SelectItem value={ALL_ENVIRONMENTS_VALUE}> | ||
| 90 | <span className="flex items-center gap-2"> | ||
| 91 | <LayersIcon aria-hidden className="size-3.5" /> | ||
| 92 | All environments | ||
| 93 | </span> | ||
| 94 | </SelectItem> | ||
| 95 | <SelectGroup> | ||
| 96 | <SelectGroupLabel>Environments</SelectGroupLabel> | ||
| 97 | {environments.map((environment) => ( | ||
| 98 | <SelectItem key={environment.environmentId} value={environment.environmentId}> | ||
| 99 | <span className="flex w-full min-w-0 items-center gap-2"> | ||
| 100 | <EnvironmentMachineIcon | ||
| 101 | aria-hidden | ||
| 102 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 103 | className="size-3.5" | ||
| 104 | /> | ||
| 105 | <span className="min-w-0 flex-1 truncate"> | ||
| 106 | {settingsScopeEnvironmentLabel(environment, environments)} | ||
| 107 | </span> | ||
| 108 | {environment.connection.phase === "connected" ? null : ( | ||
| 109 | <span className="text-xs text-muted-foreground">Offline</span> | ||
| 110 | )} | ||
| 111 | </span> | ||
| 112 | </SelectItem> | ||
| 113 | ))} | ||
| 114 | </SelectGroup> | ||
| 115 | </SelectPopup> | ||
| 116 | </Select> | ||
| 117 | |||
| 118 | <Select | ||
| 119 | value={projectValue} | ||
| 120 | onValueChange={(next) => { | ||
| 121 | if (typeof next === "string") onChange(selectProjectAxis(value, next)); | ||
| 122 | }} | ||
| 123 | > | ||
| 124 | <SelectTrigger size="compact" aria-label="Project scope" className={TRIGGER_CLASSNAME}> | ||
| 125 | <SelectValue> | ||
| 126 | <span className="flex min-w-0 items-center gap-1.5"> | ||
| 127 | {selectedGroup ? ( | ||
| 128 | <ProjectFavicon project={selectedGroup} className="size-3.5" /> | ||
| 129 | ) : null} | ||
| 130 | <span className="truncate"> | ||
| 131 | {selectedGroup?.displayName ?? | ||
| 132 | (value.project ? "Unavailable project" : "All projects")} | ||
| 133 | </span> | ||
| 134 | </span> | ||
| 135 | </SelectValue> | ||
| 136 | </SelectTrigger> | ||
| 137 | <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56"> | ||
| 138 | <SelectItem value={ALL_PROJECTS_VALUE}>All projects</SelectItem> | ||
| 139 | {groups.map((group) => ( | ||
| 140 | <SelectItem key={group.projectKey} value={group.projectKey}> | ||
| 141 | <span className="flex w-full min-w-0 items-center gap-2"> | ||
| 142 | <ProjectFavicon project={group} className="size-3.5" /> | ||
| 143 | <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | ||
| 144 | </span> | ||
| 145 | </SelectItem> | ||
| 146 | ))} | ||
| 147 | </SelectPopup> | ||
| 148 | </Select> | ||
| 149 | {resolved.kind === "unavailable" ? ( | ||
| 150 | <span className="truncate text-xs text-muted-foreground">{resolved.message}</span> | ||
| 151 | ) : null} | ||
| 152 | </div> | ||
| 153 | ); | ||
| 154 | } | ||
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-8| ... | @@ -41,11 +41,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; | ... | @@ -41,11 +41,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; |
| 41 | import { scrollToSettingsTarget } from "./settingsLayout"; | 41 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 42 | import { | 42 | import { |
| 43 | searchSettings, | 43 | searchSettings, |
| 44 | isSettingsOverviewVisible, | ||
| 44 | SETTINGS_SECTION_LABELS, | 45 | SETTINGS_SECTION_LABELS, |
| 45 | type SettingsPath, | 46 | type SettingsPath, |
| 46 | type SettingsSearchItem, | 47 | type SettingsSearchItem, |
| 47 | } from "./settingsSearch"; | 48 | } from "./settingsSearch"; |
| 48 | import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; | 49 | import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; |
| 50 | import { validateSettingsScopeSearch } from "./settingsScope"; | ||
| 49 | 51 | ||
| 50 | const SnapShotIcon = createLucideIcon("snap-shot", [ | 52 | const SnapShotIcon = createLucideIcon("snap-shot", [ |
| 51 | [ | 53 | [ |
| ... | @@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { | ... | @@ -103,6 +105,11 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 103 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { | 105 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 104 | const navigate = useNavigate(); | 106 | const navigate = useNavigate(); |
| 105 | const currentHash = useLocation({ select: (location) => location.hash }); | 107 | const currentHash = useLocation({ select: (location) => location.hash }); |
| 108 | const currentSearch = useLocation({ select: (location) => location.search }); | ||
| 109 | const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]); | ||
| 110 | const navItems = SETTINGS_NAV_ITEMS.filter( | ||
| 111 | (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), | ||
| 112 | ); | ||
| 106 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); | 113 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); |
| 107 | const searchInputRef = useRef<HTMLInputElement>(null); | 114 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 108 | const [query, setQuery] = useState(""); | 115 | const [query, setQuery] = useState(""); |
| ... | @@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -181,18 +188,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 181 | setOpenMobile(false); | 188 | setOpenMobile(false); |
| 182 | } | 189 | } |
| 183 | const targetId = item.targetId ?? item.id; | 190 | const targetId = item.targetId ?? item.id; |
| 184 | if ( | 191 | if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { |
| 185 | item.to !== "/settings/projects" && | ||
| 186 | pathname === item.to && | ||
| 187 | currentHash.replace(/^#/, "") === targetId | ||
| 188 | ) { | ||
| 189 | scrollToSettingsTarget(targetId); | 192 | scrollToSettingsTarget(targetId); |
| 190 | return; | 193 | return; |
| 191 | } | 194 | } |
| 192 | void navigate({ | 195 | void navigate({ |
| 193 | to: item.to, | 196 | to: item.to, |
| 194 | search: (previous) => | ||
| 195 | item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, | ||
| 196 | hash: targetId, | 197 | hash: targetId, |
| 197 | replace: true, | 198 | replace: true, |
| 198 | hashScrollIntoView: false, | 199 | hashScrollIntoView: false, |
| ... | @@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -319,7 +320,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 319 | </SidebarMenu> | 320 | </SidebarMenu> |
| 320 | ) : ( | 321 | ) : ( |
| 321 | <SidebarMenu className="ps-px"> | 322 | <SidebarMenu className="ps-px"> |
| 322 | {SETTINGS_NAV_ITEMS.map((item) => { | 323 | {navItems.map((item) => { |
| 323 | const Icon = item.icon; | 324 | const Icon = item.icon; |
| 324 | const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); | 325 | const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); |
| 325 | return ( | 326 | return ( |
apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | import { TriangleAlertIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import { useSharedSettingsSync } from "../../hooks/useSettings"; | ||
| 4 | import { Alert, AlertAction, AlertDescription } from "../ui/alert"; | ||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Warns when a connected environment holds different shared settings than | ||
| 9 | * the primary one, and offers to write the primary's values everywhere. | ||
| 10 | * Renders nothing when every connected environment agrees. | ||
| 11 | */ | ||
| 12 | export function SharedSettingsMismatchAlert() { | ||
| 13 | const { mismatches, applyToAll } = useSharedSettingsSync(); | ||
| 14 | if (mismatches.length === 0) { | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | const labels = mismatches.map((mismatch) => mismatch.label).join(", "); | ||
| 18 | return ( | ||
| 19 | <Alert variant="warning" className="mx-3 sm:mx-4"> | ||
| 20 | <TriangleAlertIcon /> | ||
| 21 | <AlertDescription> | ||
| 22 | Settings differ on {labels}. Thread and source control preferences are meant to match on | ||
| 23 | every environment. | ||
| 24 | </AlertDescription> | ||
| 25 | <AlertAction> | ||
| 26 | <Button variant="outline" size="xs" onClick={applyToAll}> | ||
| 27 | Apply to all | ||
| 28 | </Button> | ||
| 29 | </AlertAction> | ||
| 30 | </Alert> | ||
| 31 | ); | ||
| 32 | } | ||
apps/web/src/components/settings/SnapShotSettings.test.tsx+1| ... | @@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => { | ... | @@ -26,6 +26,7 @@ vi.mock("react/compiler-runtime", async () => { |
| 26 | }); | 26 | }); |
| 27 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); | 27 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 28 | vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); | 28 | vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 29 | vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null })); | ||
| 29 | const bridge = vi.hoisted(() => ({ | 30 | const bridge = vi.hoisted(() => ({ |
| 30 | getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(), | 31 | getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(), |
| 31 | setSnapShotShortcutSuppressed: vi.fn(), | 32 | setSnapShotShortcutSuppressed: vi.fn(), |
apps/web/src/components/settings/SourceControlSettings.tsx+31-23| ... | @@ -18,10 +18,10 @@ import { | ... | @@ -18,10 +18,10 @@ import { |
| 18 | resolveServerBackgroundActivitySettings, | 18 | resolveServerBackgroundActivitySettings, |
| 19 | } from "@t3tools/shared/backgroundActivitySettings"; | 19 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 20 | 20 | ||
| 21 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 21 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; |
| 22 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | 22 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 23 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | ||
| 23 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 24 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; | ||
| 25 | import { useEnvironmentQuery } from "../../state/query"; | 25 | import { useEnvironmentQuery } from "../../state/query"; |
| 26 | import { sourceControlEnvironment } from "../../state/sourceControl"; | 26 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 27 | import { Badge } from "../ui/badge"; | 27 | import { Badge } from "../ui/badge"; |
| ... | @@ -343,8 +343,8 @@ function DiscoveryItemRow({ | ... | @@ -343,8 +343,8 @@ function DiscoveryItemRow({ |
| 343 | } | 343 | } |
| 344 | 344 | ||
| 345 | function GitFetchIntervalSettings() { | 345 | function GitFetchIntervalSettings() { |
| 346 | const settings = usePrimarySettings(); | 346 | const settings = useScopedSettings(); |
| 347 | const updateSettings = useUpdatePrimarySettings(); | 347 | const updateSettings = useUpdateScopedSettings(); |
| 348 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | 348 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| 349 | const automaticGitFetchIntervalSeconds = durationToSeconds( | 349 | const automaticGitFetchIntervalSeconds = durationToSeconds( |
| 350 | resolvedBackgroundActivity.automaticGitFetchInterval, | 350 | resolvedBackgroundActivity.automaticGitFetchInterval, |
| ... | @@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({ | ... | @@ -498,15 +498,14 @@ function EmptySourceControlDiscovery({ |
| 498 | } | 498 | } |
| 499 | 499 | ||
| 500 | export function SourceControlSettingsPanel() { | 500 | export function SourceControlSettingsPanel() { |
| 501 | const { environments } = useEnvironments(); | 501 | const { scope, environment, connectedEnvironments } = useSettingsScope(); |
| 502 | const primaryEnvironment = usePrimaryEnvironment(); | 502 | // Discovery scans one machine's tools, so it shows the representative |
| 503 | const fallbackEnvironment = | 503 | // environment (named in the section title when several are selected); |
| 504 | environments.find((environment) => environment.connection.phase === "connected") ?? | 504 | // the settings rows above it fan out like everywhere else. |
| 505 | environments[0] ?? | ||
| 506 | null; | ||
| 507 | const environmentId = | 505 | const environmentId = |
| 508 | primaryEnvironment?.environmentId ?? fallbackEnvironment?.environmentId ?? null; | 506 | environment?.connection.phase === "connected" ? environment.environmentId : null; |
| 509 | const isPrimaryEnvironment = environmentId === primaryEnvironment?.environmentId; | 507 | const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1; |
| 508 | const environmentSuffix = aggregate && environment ? ` · ${environment.label}` : ""; | ||
| 510 | const discovery = useEnvironmentQuery( | 509 | const discovery = useEnvironmentQuery( |
| 511 | environmentId === null | 510 | environmentId === null |
| 512 | ? null | 511 | ? null |
| ... | @@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() { | ... | @@ -543,10 +542,19 @@ export function SourceControlSettingsPanel() { |
| 543 | 542 | ||
| 544 | return ( | 543 | return ( |
| 545 | <SettingsPageContainer> | 544 | <SettingsPageContainer> |
| 546 | <SharedSettingsMismatchAlert /> | 545 | <ProjectDefaultsSettings category="source-control" /> |
| 547 | {isInitialScanPending ? ( | 546 | {environmentId === null ? ( |
| 547 | <SettingsSection id={searchableSetting("source-control").id} title="Server environment"> | ||
| 548 | <p className="px-4 py-3 text-sm text-muted-foreground"> | ||
| 549 | Connect an environment to inspect its version control tools and hosting integrations. | ||
| 550 | </p> | ||
| 551 | </SettingsSection> | ||
| 552 | ) : isInitialScanPending ? ( | ||
| 548 | <> | 553 | <> |
| 549 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> | 554 | <SourceControlSectionSkeleton |
| 555 | title={`Version Control${environmentSuffix}`} | ||
| 556 | headerAction={scanButton} | ||
| 557 | /> | ||
| 550 | <SourceControlSectionSkeleton title="Source Control Providers" /> | 558 | <SourceControlSectionSkeleton title="Source Control Providers" /> |
| 551 | </> | 559 | </> |
| 552 | ) : hasDiscoveryItems ? ( | 560 | ) : hasDiscoveryItems ? ( |
| ... | @@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() { | ... | @@ -554,14 +562,12 @@ export function SourceControlSettingsPanel() { |
| 554 | {hasVersionControlSystems ? ( | 562 | {hasVersionControlSystems ? ( |
| 555 | <SettingsSection | 563 | <SettingsSection |
| 556 | id={searchableSetting("source-control").id} | 564 | id={searchableSetting("source-control").id} |
| 557 | title="Version Control" | 565 | title={`Version Control${environmentSuffix}`} |
| 558 | headerAction={scanButton} | 566 | headerAction={scanButton} |
| 559 | > | 567 | > |
| 560 | {result.versionControlSystems.map((item) => ( | 568 | {result.versionControlSystems.map((item) => ( |
| 561 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> | 569 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> |
| 562 | {item.kind === "git" && isPrimaryEnvironment ? ( | 570 | {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} |
| 563 | <GitFetchIntervalSettings /> | ||
| 564 | ) : undefined} | ||
| 565 | </DiscoveryItemRow> | 571 | </DiscoveryItemRow> |
| 566 | ))} | 572 | ))} |
| 567 | </SettingsSection> | 573 | </SettingsSection> |
| ... | @@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() { | ... | @@ -570,7 +576,11 @@ export function SourceControlSettingsPanel() { |
| 570 | {result.sourceControlProviders.length > 0 ? ( | 576 | {result.sourceControlProviders.length > 0 ? ( |
| 571 | <SettingsSection | 577 | <SettingsSection |
| 572 | id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id} | 578 | id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id} |
| 573 | title="Source Control Providers" | 579 | title={ |
| 580 | hasVersionControlSystems | ||
| 581 | ? "Source Control Providers" | ||
| 582 | : `Source Control Providers${environmentSuffix}` | ||
| 583 | } | ||
| 574 | headerAction={hasVersionControlSystems ? null : scanButton} | 584 | headerAction={hasVersionControlSystems ? null : scanButton} |
| 575 | > | 585 | > |
| 576 | {result.sourceControlProviders.map((item) => ( | 586 | {result.sourceControlProviders.map((item) => ( |
| ... | @@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() { | ... | @@ -587,8 +597,6 @@ export function SourceControlSettingsPanel() { |
| 587 | /> | 597 | /> |
| 588 | )} | 598 | )} |
| 589 | 599 | ||
| 590 | {/* Its rows are serverScoped: without a primary they render inert with | ||
| 591 | an explanation, which beats disappearing. */} | ||
| 592 | <SourceControlWritingSettingsSection /> | 600 | <SourceControlWritingSettingsSection /> |
| 593 | </SettingsPageContainer> | 601 | </SettingsPageContainer> |
| 594 | ); | 602 | ); |
apps/web/src/components/settings/SourceControlWritingSettings.test.tsx created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 2 | import { act, StrictMode, type ReactNode } from "react"; | ||
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import type { ScopedSettingsPatch } from "./scopedSettings"; | ||
| 7 | |||
| 8 | type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; | ||
| 9 | const state = vi.hoisted(() => ({ | ||
| 10 | styles: [] as WritingStyle[], | ||
| 11 | updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), | ||
| 12 | })); | ||
| 13 | |||
| 14 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); | ||
| 15 | vi.mock("./useScopedSettings", () => ({ | ||
| 16 | useScopedSettings: () => ({ | ||
| 17 | ...DEFAULT_UNIFIED_SETTINGS, | ||
| 18 | sourceControlWritingStyle: state.styles[0], | ||
| 19 | }), | ||
| 20 | useScopedSettingsMixed: () => JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]), | ||
| 21 | useUpdateScopedSettings: () => state.updateSettings, | ||
| 22 | })); | ||
| 23 | vi.mock("./SettingsScopeContext", () => ({ | ||
| 24 | useSettingsScope: () => ({ | ||
| 25 | scope: { kind: "all", environmentIds: [] }, | ||
| 26 | environment: null, | ||
| 27 | connectedEnvironments: [], | ||
| 28 | targets: state.styles.map((style) => ({ | ||
| 29 | settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style }, | ||
| 30 | })), | ||
| 31 | }), | ||
| 32 | })); | ||
| 33 | vi.mock("./useScopedModelAvailability", () => ({ | ||
| 34 | useScopedModelDisabledReason: () => () => null, | ||
| 35 | })); | ||
| 36 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); | ||
| 37 | vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] })); | ||
| 38 | vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null })); | ||
| 39 | vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) })); | ||
| 40 | vi.mock("./settingsLayout", () => ({ | ||
| 41 | SETTINGS_PICKER_TRIGGER_CLASSNAME: "", | ||
| 42 | SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => ( | ||
| 43 | <button onClick={onClick}>{`Reset ${label}`}</button> | ||
| 44 | ), | ||
| 45 | SettingsSection: ({ children }: { children: ReactNode }) => children, | ||
| 46 | SettingsRow: ({ | ||
| 47 | children, | ||
| 48 | control, | ||
| 49 | resetAction, | ||
| 50 | }: { | ||
| 51 | children: ReactNode; | ||
| 52 | control: ReactNode; | ||
| 53 | resetAction: ReactNode; | ||
| 54 | }) => ( | ||
| 55 | <div> | ||
| 56 | {control} | ||
| 57 | {resetAction} | ||
| 58 | {children} | ||
| 59 | </div> | ||
| 60 | ), | ||
| 61 | })); | ||
| 62 | vi.mock("../ui/select", () => ({ | ||
| 63 | Select: ({ children }: { children: ReactNode }) => children, | ||
| 64 | SelectItem: "span", | ||
| 65 | SelectPopup: "div", | ||
| 66 | SelectTrigger: "div", | ||
| 67 | SelectValue: "span", | ||
| 68 | })); | ||
| 69 | vi.mock("../ui/switch", () => ({ Switch: "input" })); | ||
| 70 | vi.mock("../ui/textarea", () => ({ Textarea: "textarea" })); | ||
| 71 | vi.mock("../ui/button", () => ({ Button: "button" })); | ||
| 72 | |||
| 73 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; | ||
| 74 | |||
| 75 | let renderer: ReactTestRenderer | null; | ||
| 76 | |||
| 77 | function button(label: string) { | ||
| 78 | return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!; | ||
| 79 | } | ||
| 80 | |||
| 81 | function openEditor() { | ||
| 82 | act(() => button("Write custom instructions for all").props.onClick()); | ||
| 83 | } | ||
| 84 | |||
| 85 | function editInstructions(value: string) { | ||
| 86 | act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } })); | ||
| 87 | } | ||
| 88 | |||
| 89 | function applyInstructions() { | ||
| 90 | act(() => button("Apply instructions to all").props.onClick()); | ||
| 91 | } | ||
| 92 | |||
| 93 | beforeEach(() => { | ||
| 94 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 95 | state.styles = [ | ||
| 96 | { | ||
| 97 | ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, | ||
| 98 | mode: "custom", | ||
| 99 | customInstructions: "First environment instructions", | ||
| 100 | followChangeRequestTemplates: true, | ||
| 101 | }, | ||
| 102 | { | ||
| 103 | ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, | ||
| 104 | mode: "custom", | ||
| 105 | customInstructions: "Second environment instructions", | ||
| 106 | followChangeRequestTemplates: false, | ||
| 107 | }, | ||
| 108 | ]; | ||
| 109 | state.updateSettings.mockReset().mockImplementation((patch) => { | ||
| 110 | state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle })); | ||
| 111 | }); | ||
| 112 | act(() => { | ||
| 113 | renderer = create( | ||
| 114 | <StrictMode> | ||
| 115 | <SourceControlWritingSettingsSection /> | ||
| 116 | </StrictMode>, | ||
| 117 | ); | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | |||
| 121 | afterEach(async () => { | ||
| 122 | await act(async () => renderer?.unmount()); | ||
| 123 | renderer = null; | ||
| 124 | vi.unstubAllGlobals(); | ||
| 125 | }); | ||
| 126 | |||
| 127 | describe("mixed source control instructions", () => { | ||
| 128 | it("resets mixed template preferences without replacing each environment's instructions", () => { | ||
| 129 | const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({ | ||
| 130 | mode, | ||
| 131 | customInstructions, | ||
| 132 | })); | ||
| 133 | |||
| 134 | act(() => button("Reset change request templates").props.onClick()); | ||
| 135 | |||
| 136 | expect(state.updateSettings).toHaveBeenCalledTimes(1); | ||
| 137 | expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([ | ||
| 138 | DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates, | ||
| 139 | DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates, | ||
| 140 | ]); | ||
| 141 | expect( | ||
| 142 | state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), | ||
| 143 | ).toEqual(initialInstructions); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it("resets every environment even when the representative already has default instructions", () => { | ||
| 147 | state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle }; | ||
| 148 | act(() => { | ||
| 149 | renderer!.update( | ||
| 150 | <StrictMode> | ||
| 151 | <SourceControlWritingSettingsSection /> | ||
| 152 | </StrictMode>, | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 156 | act(() => button("Reset source control writing style").props.onClick()); | ||
| 157 | |||
| 158 | expect(state.updateSettings).toHaveBeenCalledTimes(1); | ||
| 159 | expect( | ||
| 160 | state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), | ||
| 161 | ).toEqual( | ||
| 162 | [0, 1].map(() => ({ | ||
| 163 | mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode, | ||
| 164 | customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions, | ||
| 165 | })), | ||
| 166 | ); | ||
| 167 | expect(state.styles[1]!.followChangeRequestTemplates).toBe(false); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("does not write an untouched bulk draft", () => { | ||
| 171 | const initialStyles = state.styles; | ||
| 172 | openEditor(); | ||
| 173 | expect(renderer!.root.findByType("textarea").props.value).toBe(""); | ||
| 174 | expect(button("Apply instructions to all").props.disabled).toBe(true); | ||
| 175 | |||
| 176 | applyInstructions(); | ||
| 177 | expect(state.updateSettings).not.toHaveBeenCalled(); | ||
| 178 | expect(state.styles).toEqual(initialStyles); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("applies edited instructions to every selected environment", () => { | ||
| 182 | openEditor(); | ||
| 183 | editInstructions(" Keep titles concise. "); | ||
| 184 | expect(button("Apply instructions to all").props.disabled).toBe(false); | ||
| 185 | applyInstructions(); | ||
| 186 | |||
| 187 | expect(state.updateSettings).toHaveBeenCalledTimes(1); | ||
| 188 | expect(state.styles.map((style) => style.customInstructions)).toEqual([ | ||
| 189 | "Keep titles concise.", | ||
| 190 | "Keep titles concise.", | ||
| 191 | ]); | ||
| 192 | expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("allows an intentional clear after editing", () => { | ||
| 196 | openEditor(); | ||
| 197 | editInstructions("Temporary instructions"); | ||
| 198 | editInstructions(""); | ||
| 199 | expect(button("Apply instructions to all").props.disabled).toBe(false); | ||
| 200 | applyInstructions(); | ||
| 201 | |||
| 202 | expect(state.updateSettings).toHaveBeenCalledTimes(1); | ||
| 203 | expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]); | ||
| 204 | }); | ||
| 205 | |||
| 206 | it("shows the plain editor once instructions agree, even while templates differ", () => { | ||
| 207 | openEditor(); | ||
| 208 | editInstructions("Shared instructions"); | ||
| 209 | applyInstructions(); | ||
| 210 | |||
| 211 | expect(state.updateSettings).toHaveBeenCalledTimes(1); | ||
| 212 | expect(state.styles.map((style) => style.customInstructions)).toEqual([ | ||
| 213 | "Shared instructions", | ||
| 214 | "Shared instructions", | ||
| 215 | ]); | ||
| 216 | // Template preferences still differ, but that is the templates row's | ||
| 217 | // concern: the instructions editor is no longer a bulk draft. | ||
| 218 | expect(button("Write custom instructions for all")).toBeUndefined(); | ||
| 219 | expect(renderer!.root.findByType("textarea").props.defaultValue).toBe("Shared instructions"); | ||
| 220 | }); | ||
| 221 | }); | ||
apps/web/src/components/settings/SourceControlWritingSettings.tsx+169-63| ... | @@ -1,12 +1,21 @@ | ... | @@ -1,12 +1,21 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | 1 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { useRef } from "react"; | 2 | import { useRef, useState } from "react"; |
| 4 | import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts"; | 3 | import type { |
| 4 | ProviderInstanceId, | ||
| 5 | ServerSettings, | ||
| 6 | SourceControlWritingStyleMode, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 5 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 8 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 7 | import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; | 10 | import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; |
| 8 | 11 | ||
| 9 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 12 | import { |
| 13 | useScopedSettings, | ||
| 14 | useScopedSettingsMixed, | ||
| 15 | useUpdateScopedSettings, | ||
| 16 | } from "./useScopedSettings"; | ||
| 17 | import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; | ||
| 18 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 10 | import { | 19 | import { |
| 11 | applyProviderInstanceSettings, | 20 | applyProviderInstanceSettings, |
| 12 | deriveProviderInstanceEntries, | 21 | deriveProviderInstanceEntries, |
| ... | @@ -16,12 +25,13 @@ import { | ... | @@ -16,12 +25,13 @@ import { |
| 16 | getCustomModelOptionsByInstance, | 25 | getCustomModelOptionsByInstance, |
| 17 | resolveAppModelSelectionState, | 26 | resolveAppModelSelectionState, |
| 18 | } from "../../modelSelection"; | 27 | } from "../../modelSelection"; |
| 19 | import { primaryServerProvidersAtom } from "../../state/server"; | 28 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 20 | import { usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 21 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | 29 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 22 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 30 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 23 | import { Switch } from "../ui/switch"; | 31 | import { Switch } from "../ui/switch"; |
| 24 | import { Textarea } from "../ui/textarea"; | 32 | import { Textarea } from "../ui/textarea"; |
| 33 | import { toastManager } from "../ui/toast"; | ||
| 34 | import { Button } from "../ui/button"; | ||
| 25 | import { | 35 | import { |
| 26 | SETTINGS_PICKER_TRIGGER_CLASSNAME, | 36 | SETTINGS_PICKER_TRIGGER_CLASSNAME, |
| 27 | SettingResetButton, | 37 | SettingResetButton, |
| ... | @@ -48,16 +58,41 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr | ... | @@ -48,16 +58,41 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr |
| 48 | }; | 58 | }; |
| 49 | 59 | ||
| 50 | export function SourceControlWritingSettingsSection() { | 60 | export function SourceControlWritingSettingsSection() { |
| 51 | const settings = usePrimarySettings(); | 61 | const settings = useScopedSettings(); |
| 52 | const updateSettings = useUpdatePrimarySettings(); | 62 | const updateSettings = useUpdateScopedSettings(); |
| 53 | const navigate = useNavigate(); | 63 | const navigate = useNavigate(); |
| 54 | const environmentId = usePrimaryEnvironmentId(); | 64 | const { environment, connectedEnvironments, targets } = useSettingsScope(); |
| 55 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | 65 | // The representative supplies the provider list; a model choice is checked |
| 66 | // against every target before it fans out. | ||
| 67 | const environmentId = environment?.environmentId ?? null; | ||
| 68 | const hasServerTargets = connectedEnvironments.length > 0; | ||
| 69 | const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; | ||
| 70 | // The writing style is one object; each control only cares about its own field. | ||
| 71 | const styleFieldMixed = (field: keyof ServerSettings["sourceControlWritingStyle"]) => { | ||
| 72 | const first = targets[0]; | ||
| 73 | return ( | ||
| 74 | first !== undefined && | ||
| 75 | targets.some( | ||
| 76 | (candidate) => | ||
| 77 | candidate.settings.sourceControlWritingStyle[field] !== | ||
| 78 | first.settings.sourceControlWritingStyle[field], | ||
| 79 | ) | ||
| 80 | ); | ||
| 81 | }; | ||
| 82 | const modeMixed = styleFieldMixed("mode"); | ||
| 83 | const instructionsMixed = styleFieldMixed("customInstructions"); | ||
| 84 | const templatesMixed = styleFieldMixed("followChangeRequestTemplates"); | ||
| 85 | const writingStyleMixed = modeMixed || instructionsMixed; | ||
| 86 | const mixedWriterModel = useScopedSettingsMixed(["sourceControlWriterModelSelection"]); | ||
| 56 | const customInstructionsRef = useRef<HTMLTextAreaElement>(null); | 87 | const customInstructionsRef = useRef<HTMLTextAreaElement>(null); |
| 88 | const [editingAllInstructions, setEditingAllInstructions] = useState(false); | ||
| 89 | const [allInstructions, setAllInstructions] = useState<string | null>(null); | ||
| 57 | const style = settings.sourceControlWritingStyle; | 90 | const style = settings.sourceControlWritingStyle; |
| 58 | const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; | 91 | const defaults = DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; |
| 59 | const isSourceControlWritingStyleDirty = | 92 | const isSourceControlWritingStyleDirty = |
| 60 | style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; | 93 | writingStyleMixed || |
| 94 | style.mode !== defaults.mode || | ||
| 95 | style.customInstructions !== defaults.customInstructions; | ||
| 61 | 96 | ||
| 62 | const textGenerationProviders = serverProviders.filter( | 97 | const textGenerationProviders = serverProviders.filter( |
| 63 | (provider) => provider.supportsTextGeneration !== false, | 98 | (provider) => provider.supportsTextGeneration !== false, |
| ... | @@ -87,11 +122,14 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -87,11 +122,14 @@ export function SourceControlWritingSettingsSection() { |
| 87 | activeSelection.instanceId, | 122 | activeSelection.instanceId, |
| 88 | activeSelection.model, | 123 | activeSelection.model, |
| 89 | ); | 124 | ); |
| 125 | const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries); | ||
| 90 | 126 | ||
| 91 | return ( | 127 | return ( |
| 92 | <SettingsSection id="source-control-text-generation" title="Text generation"> | 128 | <SettingsSection id="source-control-text-generation" title="Text generation"> |
| 93 | <SettingsRow | 129 | <SettingsRow |
| 94 | serverScoped | 130 | serverScoped |
| 131 | settingKeys={["sourceControlWritingStyle"]} | ||
| 132 | mixed={writingStyleMixed} | ||
| 95 | {...searchableSetting("source-control-writing-style")} | 133 | {...searchableSetting("source-control-writing-style")} |
| 96 | description={MODE_OPTIONS[style.mode].description} | 134 | description={MODE_OPTIONS[style.mode].description} |
| 97 | resetAction={ | 135 | resetAction={ |
| ... | @@ -111,7 +149,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -111,7 +149,7 @@ export function SourceControlWritingSettingsSection() { |
| 111 | } | 149 | } |
| 112 | control={ | 150 | control={ |
| 113 | <Select | 151 | <Select |
| 114 | value={style.mode} | 152 | value={modeMixed ? null : style.mode} |
| 115 | onValueChange={(value) => { | 153 | onValueChange={(value) => { |
| 116 | const customInstructions = customInstructionsRef.current?.value.trim(); | 154 | const customInstructions = customInstructionsRef.current?.value.trim(); |
| 117 | updateSettings({ | 155 | updateSettings({ |
| ... | @@ -127,7 +165,11 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -127,7 +165,11 @@ export function SourceControlWritingSettingsSection() { |
| 127 | className="w-full sm:w-56" | 165 | className="w-full sm:w-56" |
| 128 | aria-label="Source control writing style" | 166 | aria-label="Source control writing style" |
| 129 | > | 167 | > |
| 130 | <SelectValue>{MODE_OPTIONS[style.mode].label}</SelectValue> | 168 | <SelectValue> |
| 169 | {(value: SourceControlWritingStyleMode | null) => | ||
| 170 | value === null ? "Mixed" : MODE_OPTIONS[value].label | ||
| 171 | } | ||
| 172 | </SelectValue> | ||
| 131 | </SelectTrigger> | 173 | </SelectTrigger> |
| 132 | <SelectPopup align="end" alignItemWithTrigger={false}> | 174 | <SelectPopup align="end" alignItemWithTrigger={false}> |
| 133 | {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => ( | 175 | {(Object.keys(MODE_OPTIONS) as SourceControlWritingStyleMode[]).map((mode) => ( |
| ... | @@ -139,7 +181,49 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -139,7 +181,49 @@ export function SourceControlWritingSettingsSection() { |
| 139 | </Select> | 181 | </Select> |
| 140 | } | 182 | } |
| 141 | > | 183 | > |
| 142 | {style.mode === "custom" ? ( | 184 | {writingStyleMixed ? ( |
| 185 | <div className="mt-3 max-w-2xl space-y-2 pb-3.5"> | ||
| 186 | {editingAllInstructions ? ( | ||
| 187 | <> | ||
| 188 | <Textarea | ||
| 189 | value={allInstructions ?? ""} | ||
| 190 | onChange={(event) => setAllInstructions(event.target.value)} | ||
| 191 | rows={4} | ||
| 192 | aria-label="Custom source control instructions for all selected environments" | ||
| 193 | placeholder="Write the instructions each selected environment should use." | ||
| 194 | /> | ||
| 195 | <Button | ||
| 196 | size="sm" | ||
| 197 | variant="outline" | ||
| 198 | disabled={allInstructions === null} | ||
| 199 | onClick={() => { | ||
| 200 | if (allInstructions === null) return; | ||
| 201 | updateSettings({ | ||
| 202 | sourceControlWritingStyle: { | ||
| 203 | mode: "custom", | ||
| 204 | customInstructions: allInstructions.trim(), | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | setEditingAllInstructions(false); | ||
| 208 | }} | ||
| 209 | > | ||
| 210 | Apply instructions to all | ||
| 211 | </Button> | ||
| 212 | </> | ||
| 213 | ) : ( | ||
| 214 | <Button | ||
| 215 | size="sm" | ||
| 216 | variant="outline" | ||
| 217 | onClick={() => { | ||
| 218 | setAllInstructions(null); | ||
| 219 | setEditingAllInstructions(true); | ||
| 220 | }} | ||
| 221 | > | ||
| 222 | Write custom instructions for all | ||
| 223 | </Button> | ||
| 224 | )} | ||
| 225 | </div> | ||
| 226 | ) : style.mode === "custom" ? ( | ||
| 143 | <div className="mt-3 max-w-2xl pb-3.5"> | 227 | <div className="mt-3 max-w-2xl pb-3.5"> |
| 144 | <Textarea | 228 | <Textarea |
| 145 | key={style.customInstructions} | 229 | key={style.customInstructions} |
| ... | @@ -161,9 +245,12 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -161,9 +245,12 @@ export function SourceControlWritingSettingsSection() { |
| 161 | 245 | ||
| 162 | <SettingsRow | 246 | <SettingsRow |
| 163 | serverScoped | 247 | serverScoped |
| 248 | settingKeys={["sourceControlWritingStyle"]} | ||
| 249 | mixed={templatesMixed} | ||
| 164 | {...searchableSetting("follow-change-request-templates")} | 250 | {...searchableSetting("follow-change-request-templates")} |
| 165 | description="Use the repository's template for change request descriptions when available." | 251 | description="Use the repository's template for change request descriptions when available." |
| 166 | resetAction={ | 252 | resetAction={ |
| 253 | templatesMixed || | ||
| 167 | style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? ( | 254 | style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? ( |
| 168 | <SettingResetButton | 255 | <SettingResetButton |
| 169 | label="change request templates" | 256 | label="change request templates" |
| ... | @@ -179,7 +266,8 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -179,7 +266,8 @@ export function SourceControlWritingSettingsSection() { |
| 179 | } | 266 | } |
| 180 | control={ | 267 | control={ |
| 181 | <Switch | 268 | <Switch |
| 182 | checked={style.followChangeRequestTemplates} | 269 | mixed={templatesMixed} |
| 270 | checked={templatesMixed ? false : style.followChangeRequestTemplates} | ||
| 183 | onCheckedChange={(checked) => | 271 | onCheckedChange={(checked) => |
| 184 | updateSettings({ | 272 | updateSettings({ |
| 185 | sourceControlWritingStyle: { | 273 | sourceControlWritingStyle: { |
| ... | @@ -194,59 +282,77 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -194,59 +282,77 @@ export function SourceControlWritingSettingsSection() { |
| 194 | 282 | ||
| 195 | <SettingsRow | 283 | <SettingsRow |
| 196 | serverScoped | 284 | serverScoped |
| 285 | settingKeys={["sourceControlWriterModelSelection"]} | ||
| 197 | {...searchableSetting("source-control-writer-model")} | 286 | {...searchableSetting("source-control-writer-model")} |
| 198 | description="Model for source control text and branch or bookmark names. Off uses the global default." | 287 | description="Model for source control text and branch or bookmark names. Off uses the environment's text generation model." |
| 199 | control={ | 288 | control={ |
| 200 | <div className="flex flex-wrap items-center justify-end gap-2"> | 289 | !hasServerTargets ? ( |
| 201 | {usesDedicatedModel && !canEnableDedicatedModel ? ( | 290 | <span className="text-sm text-muted-foreground"> |
| 202 | <span className="text-sm text-muted-foreground"> | 291 | Connect an environment to choose its source control writer model. |
| 203 | No text generation providers available. | 292 | </span> |
| 204 | </span> | 293 | ) : ( |
| 205 | ) : null} | 294 | <div className="flex flex-wrap items-center justify-end gap-2"> |
| 206 | {usesDedicatedModel && canEnableDedicatedModel ? ( | 295 | {usesDedicatedModel && !canEnableDedicatedModel ? ( |
| 207 | <ProviderModelPicker | 296 | <span className="text-sm text-muted-foreground"> |
| 208 | activeInstanceId={activeSelection.instanceId} | 297 | No text generation providers available. |
| 209 | model={activeSelection.model} | 298 | </span> |
| 210 | lockedProvider={null} | 299 | ) : null} |
| 211 | instanceEntries={instanceEntries} | 300 | {usesDedicatedModel && canEnableDedicatedModel ? ( |
| 212 | modelOptionsByInstance={modelOptionsByInstance} | 301 | <ProviderModelPicker |
| 213 | triggerVariant="outline" | 302 | activeInstanceId={activeSelection.instanceId} |
| 214 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} | 303 | model={activeSelection.model} |
| 215 | triggerAriaLabel="Source control writer model" | 304 | lockedProvider={null} |
| 216 | {...(environmentId | 305 | instanceEntries={instanceEntries} |
| 217 | ? { | 306 | modelOptionsByInstance={modelOptionsByInstance} |
| 218 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => { | 307 | triggerVariant="outline" |
| 219 | void navigate({ | 308 | triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} |
| 220 | to: "/settings/providers", | 309 | triggerAriaLabel="Source control writer model" |
| 221 | search: { environmentId, instanceId }, | 310 | {...(mixedWriterModel ? { triggerLabel: "Mixed" } : {})} |
| 222 | }); | 311 | {...(environmentId |
| 223 | }, | 312 | ? { |
| 313 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => { | ||
| 314 | void navigate({ | ||
| 315 | to: "/settings/providers", | ||
| 316 | search: { environmentId, instanceId }, | ||
| 317 | }); | ||
| 318 | }, | ||
| 319 | } | ||
| 320 | : {})} | ||
| 321 | getModelDisabledReason={writerModelDisabledReason} | ||
| 322 | onInstanceModelChange={(instanceId, model) => { | ||
| 323 | const reason = writerModelDisabledReason(instanceId, model); | ||
| 324 | if (reason) { | ||
| 325 | toastManager.add({ | ||
| 326 | type: "error", | ||
| 327 | title: "Source control writer model not saved", | ||
| 328 | description: reason, | ||
| 329 | }); | ||
| 330 | return; | ||
| 224 | } | 331 | } |
| 225 | : {})} | 332 | updateSettings({ |
| 226 | onInstanceModelChange={(instanceId, model) => { | 333 | sourceControlWriterModelSelection: createModelSelection(instanceId, model), |
| 334 | }); | ||
| 335 | }} | ||
| 336 | /> | ||
| 337 | ) : null} | ||
| 338 | <Switch | ||
| 339 | checked={usesDedicatedModel} | ||
| 340 | disabled={!usesDedicatedModel && !canEnableDedicatedModel} | ||
| 341 | onCheckedChange={(checked) => | ||
| 227 | updateSettings({ | 342 | updateSettings({ |
| 228 | sourceControlWriterModelSelection: createModelSelection(instanceId, model), | 343 | sourceControlWriterModelSelection: checked |
| 229 | }); | 344 | ? createModelSelection( |
| 230 | }} | 345 | defaultModelSelection.instanceId, |
| 346 | defaultModelSelection.model, | ||
| 347 | defaultModelSelection.options, | ||
| 348 | ) | ||
| 349 | : null, | ||
| 350 | }) | ||
| 351 | } | ||
| 352 | aria-label="Use a separate source control writer model" | ||
| 231 | /> | 353 | /> |
| 232 | ) : null} | 354 | </div> |
| 233 | <Switch | 355 | ) |
| 234 | checked={usesDedicatedModel} | ||
| 235 | disabled={!usesDedicatedModel && !canEnableDedicatedModel} | ||
| 236 | onCheckedChange={(checked) => | ||
| 237 | updateSettings({ | ||
| 238 | sourceControlWriterModelSelection: checked | ||
| 239 | ? createModelSelection( | ||
| 240 | defaultModelSelection.instanceId, | ||
| 241 | defaultModelSelection.model, | ||
| 242 | defaultModelSelection.options, | ||
| 243 | ) | ||
| 244 | : null, | ||
| 245 | }) | ||
| 246 | } | ||
| 247 | aria-label="Use a separate source control writer model" | ||
| 248 | /> | ||
| 249 | </div> | ||
| 250 | } | 356 | } |
| 251 | /> | 357 | /> |
| 252 | </SettingsSection> | 358 | </SettingsSection> |
apps/web/src/components/settings/scopedSettings.test.ts created+450| ... | @@ -0,0 +1,450 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | type ServerSettings, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | ||
| 10 | import { | ||
| 11 | listProjectOverrides, | ||
| 12 | persistScopedSettingsPatch, | ||
| 13 | planProjectOverridesClear, | ||
| 14 | planScopedSettingsClear, | ||
| 15 | planScopedSettingsPatch, | ||
| 16 | resolveScopedSettingsTargets, | ||
| 17 | scopedSettingsAreMixed, | ||
| 18 | scopedSettingsSource, | ||
| 19 | selectScopedSettingsEnvironments, | ||
| 20 | } from "./scopedSettings"; | ||
| 21 | import { resolveSettingsScope } from "./settingsScope"; | ||
| 22 | |||
| 23 | function environment( | ||
| 24 | id: string, | ||
| 25 | options: { | ||
| 26 | connected?: boolean; | ||
| 27 | loaded?: boolean; | ||
| 28 | settings?: Partial<ServerSettings>; | ||
| 29 | projectOverrides?: boolean; | ||
| 30 | } = {}, | ||
| 31 | ) { | ||
| 32 | return { | ||
| 33 | environmentId: EnvironmentId.make(id), | ||
| 34 | label: id, | ||
| 35 | connection: { | ||
| 36 | phase: options.connected === false ? ("offline" as const) : ("connected" as const), | ||
| 37 | }, | ||
| 38 | serverConfig: | ||
| 39 | options.loaded === false | ||
| 40 | ? null | ||
| 41 | : { | ||
| 42 | settings: { ...DEFAULT_SERVER_SETTINGS, ...options.settings }, | ||
| 43 | environment: { | ||
| 44 | capabilities: { projectSettingsOverrides: options.projectOverrides !== false }, | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | const laptop = environment("Laptop"); | ||
| 51 | const server = environment("Server"); | ||
| 52 | const offline = environment("Offline", { connected: false }); | ||
| 53 | const loading = environment("Loading", { loaded: false }); | ||
| 54 | const environments = [laptop, server, offline, loading]; | ||
| 55 | const all = resolveSettingsScope({}, [], environments); | ||
| 56 | const named = resolveSettingsScope({ machine: server.environmentId }, [], environments); | ||
| 57 | |||
| 58 | const projectId = ProjectId.make("project"); | ||
| 59 | const laptopProjectId = ProjectId.make("laptop-project"); | ||
| 60 | const member = { | ||
| 61 | id: projectId, | ||
| 62 | environmentId: server.environmentId, | ||
| 63 | title: "Project", | ||
| 64 | workspaceRoot: "/repo", | ||
| 65 | physicalProjectKey: `${server.environmentId}:/repo`, | ||
| 66 | environmentLabel: server.label, | ||
| 67 | defaultModelSelection: null, | ||
| 68 | scripts: [], | ||
| 69 | createdAt: "2026-09-07T00:00:00.000Z", | ||
| 70 | updatedAt: "2026-09-07T00:00:00.000Z", | ||
| 71 | }; | ||
| 72 | const laptopMember = { | ||
| 73 | ...member, | ||
| 74 | id: laptopProjectId, | ||
| 75 | environmentId: laptop.environmentId, | ||
| 76 | physicalProjectKey: `${laptop.environmentId}:/repo`, | ||
| 77 | environmentLabel: laptop.label, | ||
| 78 | }; | ||
| 79 | const group: SidebarProjectSnapshot = { | ||
| 80 | ...member, | ||
| 81 | projectKey: "project-group", | ||
| 82 | displayName: "Project", | ||
| 83 | memberProjects: [member, laptopMember], | ||
| 84 | memberProjectRefs: [ | ||
| 85 | { environmentId: server.environmentId, projectId }, | ||
| 86 | { environmentId: laptop.environmentId, projectId: laptopProjectId }, | ||
| 87 | ], | ||
| 88 | groupedProjectCount: 2, | ||
| 89 | environmentPresence: "remote-only", | ||
| 90 | allRemoteMembersAreDesktopLocal: false, | ||
| 91 | allRemoteMembersAreWsl: false, | ||
| 92 | remoteEnvironmentLabels: [server.label, laptop.label], | ||
| 93 | }; | ||
| 94 | const project = resolveSettingsScope({ project: group.projectKey }, [group], environments); | ||
| 95 | const checkout = resolveSettingsScope( | ||
| 96 | { project: group.projectKey, machine: server.environmentId, checkout: member.physicalProjectKey }, | ||
| 97 | [group], | ||
| 98 | environments, | ||
| 99 | ); | ||
| 100 | |||
| 101 | describe("scoped settings targets", () => { | ||
| 102 | it("uses the named environment even when a different primary is available", () => { | ||
| 103 | const selected = selectScopedSettingsEnvironments(named, environments, laptop.environmentId); | ||
| 104 | expect(selected.environments).toEqual([server]); | ||
| 105 | expect(selected.environment).toBe(server); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("keeps an offline named environment selected without falling back to primary", () => { | ||
| 109 | const scope = resolveSettingsScope({ machine: offline.environmentId }, [], environments); | ||
| 110 | const selected = selectScopedSettingsEnvironments(scope, environments, laptop.environmentId); | ||
| 111 | expect(selected.environments).toEqual([offline]); | ||
| 112 | expect(selected.connectedEnvironments).toEqual([]); | ||
| 113 | expect(selected.environment).toBeNull(); | ||
| 114 | expect( | ||
| 115 | planScopedSettingsPatch(scope, environments, { enableProviderUpdateChecks: false }), | ||
| 116 | ).toMatchObject({ | ||
| 117 | serverWrites: [], | ||
| 118 | unavailableReason: "Connect Offline to save this setting.", | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("prefers the selected primary as the aggregate representative without including disconnected targets", () => { | ||
| 123 | const selected = selectScopedSettingsEnvironments(all, environments, server.environmentId); | ||
| 124 | expect(selected.environment).toBe(server); | ||
| 125 | expect(selected.environments).toEqual(environments); | ||
| 126 | expect(selected.connectedEnvironments).toEqual([laptop, server]); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("resolves each member's effective settings and source at project scope", () => { | ||
| 130 | const overridden = environment("Server", { | ||
| 131 | settings: { | ||
| 132 | defaultAutoPull: false, | ||
| 133 | projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } }, | ||
| 134 | }, | ||
| 135 | }); | ||
| 136 | const targets = resolveScopedSettingsTargets(project, [laptop, overridden]); | ||
| 137 | expect(targets.map((target) => [target.projectId, target.settings.defaultAutoPull])).toEqual([ | ||
| 138 | [projectId, true], | ||
| 139 | [laptopProjectId, false], | ||
| 140 | ]); | ||
| 141 | expect(scopedSettingsSource(targets, ["defaultAutoPull"])).toBe("mixed"); | ||
| 142 | expect(scopedSettingsSource([targets[0]!], ["defaultAutoPull"])).toBe("project"); | ||
| 143 | expect(scopedSettingsSource(targets, ["enableProviderUpdateChecks"])).toBe("environment"); | ||
| 144 | expect(scopedSettingsAreMixed(targets, ["defaultAutoPull"])).toBe(true); | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | describe("scoped settings writes", () => { | ||
| 149 | it("isolates a formerly shared server preference to the named environment", async () => { | ||
| 150 | const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" }); | ||
| 151 | const persistClient = vi.fn(); | ||
| 152 | await persistScopedSettingsPatch( | ||
| 153 | planScopedSettingsPatch(named, environments, { sidebarAutoSettleOnMerge: false }), | ||
| 154 | persistServer, | ||
| 155 | persistClient, | ||
| 156 | ); | ||
| 157 | expect(persistServer.mock.calls).toEqual([ | ||
| 158 | [ | ||
| 159 | { | ||
| 160 | environmentId: server.environmentId, | ||
| 161 | input: { patch: { sidebarAutoSettleOnMerge: false } }, | ||
| 162 | }, | ||
| 163 | ], | ||
| 164 | ]); | ||
| 165 | expect(persistClient).not.toHaveBeenCalled(); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it("writes an aggregate preference only to connected environments with loaded configuration", async () => { | ||
| 169 | const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" }); | ||
| 170 | const persistClient = vi.fn(); | ||
| 171 | await persistScopedSettingsPatch( | ||
| 172 | planScopedSettingsPatch(all, environments, { enableProviderUpdateChecks: false }), | ||
| 173 | persistServer, | ||
| 174 | persistClient, | ||
| 175 | ); | ||
| 176 | expect(persistServer.mock.calls.map(([input]) => input.environmentId)).toEqual([ | ||
| 177 | laptop.environmentId, | ||
| 178 | server.environmentId, | ||
| 179 | ]); | ||
| 180 | expect(persistClient).not.toHaveBeenCalled(); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("persists client keys locally at any scope alongside server keys", async () => { | ||
| 184 | const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" }); | ||
| 185 | const persistClient = vi.fn(); | ||
| 186 | await persistScopedSettingsPatch( | ||
| 187 | planScopedSettingsPatch(named, environments, { | ||
| 188 | diffIgnoreWhitespace: false, | ||
| 189 | enableProviderUpdateChecks: false, | ||
| 190 | }), | ||
| 191 | persistServer, | ||
| 192 | persistClient, | ||
| 193 | ); | ||
| 194 | expect(persistClient).toHaveBeenCalledExactlyOnceWith({ diffIgnoreWhitespace: false }); | ||
| 195 | expect(persistServer).toHaveBeenCalledExactlyOnceWith({ | ||
| 196 | environmentId: server.environmentId, | ||
| 197 | input: { patch: { enableProviderUpdateChecks: false } }, | ||
| 198 | }); | ||
| 199 | }); | ||
| 200 | |||
| 201 | it("writes project overrides into each member's entry on its environment", () => { | ||
| 202 | const withExisting = environment("Server", { | ||
| 203 | settings: { | ||
| 204 | projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } }, | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | const plan = planScopedSettingsPatch(project, [laptop, withExisting], { | ||
| 208 | defaultAutoPull: true, | ||
| 209 | }); | ||
| 210 | expect(plan.unavailableReason).toBeNull(); | ||
| 211 | expect(plan.serverWrites).toEqual([ | ||
| 212 | { | ||
| 213 | environmentId: server.environmentId, | ||
| 214 | label: server.label, | ||
| 215 | patch: { | ||
| 216 | projectSettingsOverrides: { | ||
| 217 | [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true }, | ||
| 218 | }, | ||
| 219 | }, | ||
| 220 | }, | ||
| 221 | { | ||
| 222 | environmentId: laptop.environmentId, | ||
| 223 | label: laptop.label, | ||
| 224 | patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } }, | ||
| 225 | }, | ||
| 226 | ]); | ||
| 227 | expect( | ||
| 228 | planScopedSettingsPatch(checkout, [laptop, server], { defaultAutoPull: true }), | ||
| 229 | ).toMatchObject({ | ||
| 230 | serverWrites: [{ environmentId: server.environmentId }], | ||
| 231 | }); | ||
| 232 | }); | ||
| 233 | |||
| 234 | it("refuses environment-wide keys and older servers at project scope", () => { | ||
| 235 | expect( | ||
| 236 | planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }), | ||
| 237 | ).toMatchObject({ | ||
| 238 | serverWrites: [], | ||
| 239 | unavailableReason: "This setting is environment-wide and cannot be overridden by a project.", | ||
| 240 | }); | ||
| 241 | const legacy = environment("Server", { projectOverrides: false }); | ||
| 242 | expect( | ||
| 243 | planScopedSettingsPatch(checkout, [laptop, legacy], { defaultAutoPull: true }), | ||
| 244 | ).toMatchObject({ serverWrites: [], unavailableReason: expect.stringContaining("update") }); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("clears overrides per member and removes an emptied entry", () => { | ||
| 248 | const withOverrides = environment("Server", { | ||
| 249 | settings: { | ||
| 250 | projectSettingsOverrides: { | ||
| 251 | [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, | ||
| 252 | }, | ||
| 253 | }, | ||
| 254 | }); | ||
| 255 | const plan = planScopedSettingsClear(checkout, [laptop, withOverrides], ["defaultAutoPull"]); | ||
| 256 | expect(plan.serverWrites).toEqual([ | ||
| 257 | { | ||
| 258 | environmentId: server.environmentId, | ||
| 259 | label: server.label, | ||
| 260 | patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } }, | ||
| 261 | }, | ||
| 262 | ]); | ||
| 263 | expect( | ||
| 264 | planScopedSettingsClear( | ||
| 265 | checkout, | ||
| 266 | [laptop, withOverrides], | ||
| 267 | ["defaultAutoPull", "enableAgentBrowserAccess"], | ||
| 268 | ).serverWrites[0]?.patch, | ||
| 269 | ).toEqual({ projectSettingsOverrides: { [projectId]: null } }); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it("never substitutes an environment-default write for an invalid scope", () => { | ||
| 273 | const scope = resolveSettingsScope({ machine: "removed" }, [group], environments); | ||
| 274 | const plan = planScopedSettingsPatch(scope, environments, { enableAgentBrowserAccess: false }); | ||
| 275 | expect(plan.serverWrites).toEqual([]); | ||
| 276 | expect(plan.hasClientWrite).toBe(false); | ||
| 277 | expect(plan.unavailableReason).not.toBeNull(); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("waits for every target and identifies both RPC failures and rejected writes", async () => { | ||
| 281 | const third = environment("Third"); | ||
| 282 | const fourth = environment("Fourth"); | ||
| 283 | const selected = [...environments, third, fourth]; | ||
| 284 | const scope = resolveSettingsScope({}, [], selected); | ||
| 285 | const persistServer = vi | ||
| 286 | .fn() | ||
| 287 | .mockResolvedValueOnce({ _tag: "Success" }) | ||
| 288 | .mockResolvedValueOnce({ _tag: "Failure" }) | ||
| 289 | .mockRejectedValueOnce(new Error("Disconnected during save")) | ||
| 290 | .mockResolvedValueOnce({ _tag: "Success" }); | ||
| 291 | const result = await persistScopedSettingsPatch( | ||
| 292 | planScopedSettingsPatch(scope, selected, { enableAgentBrowserAccess: false }), | ||
| 293 | persistServer, | ||
| 294 | vi.fn(), | ||
| 295 | ); | ||
| 296 | expect(result.savedEnvironmentCount).toBe(2); | ||
| 297 | expect(result.failedEnvironments.map(({ label }) => label)).toEqual([ | ||
| 298 | server.label, | ||
| 299 | third.label, | ||
| 300 | ]); | ||
| 301 | expect(persistServer).toHaveBeenCalledTimes(4); | ||
| 302 | }); | ||
| 303 | }); | ||
| 304 | |||
| 305 | describe("scoped settings mixed values", () => { | ||
| 306 | it("compares only requested settings across connected targets", () => { | ||
| 307 | const changed = environment("Changed", { settings: { enableAgentBrowserAccess: false } }); | ||
| 308 | const targets = resolveScopedSettingsTargets(all, [laptop, changed]); | ||
| 309 | expect(scopedSettingsAreMixed(targets, ["enableAgentBrowserAccess"])).toBe(true); | ||
| 310 | expect(scopedSettingsAreMixed(targets, ["enableProviderUpdateChecks"])).toBe(false); | ||
| 311 | expect(scopedSettingsAreMixed([], ["enableAgentBrowserAccess"])).toBe(false); | ||
| 312 | }); | ||
| 313 | |||
| 314 | it("treats independently decoded equal nested settings as the same value", () => { | ||
| 315 | const style = { | ||
| 316 | ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle, | ||
| 317 | mode: "custom" as const, | ||
| 318 | customInstructions: "Use plain language", | ||
| 319 | }; | ||
| 320 | const first = environment("First", { settings: { sourceControlWritingStyle: { ...style } } }); | ||
| 321 | const second = environment("Second", { settings: { sourceControlWritingStyle: { ...style } } }); | ||
| 322 | const targets = resolveScopedSettingsTargets(all, [first, second]); | ||
| 323 | expect(scopedSettingsAreMixed(targets, ["sourceControlWritingStyle"])).toBe(false); | ||
| 324 | }); | ||
| 325 | }); | ||
| 326 | |||
| 327 | describe("project overrides at environment scope", () => { | ||
| 328 | const laptop = EnvironmentId.make("laptop"); | ||
| 329 | const desk = EnvironmentId.make("desk"); | ||
| 330 | const fleet = ProjectId.make("fleet"); | ||
| 331 | const t3 = ProjectId.make("t3"); | ||
| 332 | const environment = ( | ||
| 333 | environmentId: EnvironmentId, | ||
| 334 | overrides: ServerSettings["projectSettingsOverrides"], | ||
| 335 | ) => ({ | ||
| 336 | environmentId, | ||
| 337 | label: environmentId, | ||
| 338 | connection: { phase: "connected" as const }, | ||
| 339 | serverConfig: { | ||
| 340 | settings: { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: overrides }, | ||
| 341 | environment: { capabilities: { projectSettingsOverrides: true } }, | ||
| 342 | }, | ||
| 343 | }); | ||
| 344 | |||
| 345 | it("lists only the projects that override the keys", () => { | ||
| 346 | const entries = listProjectOverrides( | ||
| 347 | [ | ||
| 348 | environment(laptop, { | ||
| 349 | [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" }, | ||
| 350 | [t3]: { defaultThreadEnvMode: "local" }, | ||
| 351 | }), | ||
| 352 | environment(desk, { [fleet]: { defaultAutoPull: false } }), | ||
| 353 | ], | ||
| 354 | ["defaultAutoPull"], | ||
| 355 | ); | ||
| 356 | expect(entries).toEqual([ | ||
| 357 | { environmentId: laptop, projectId: fleet }, | ||
| 358 | { environmentId: desk, projectId: fleet }, | ||
| 359 | ]); | ||
| 360 | }); | ||
| 361 | |||
| 362 | it("clears only those keys and drops entries that become empty", () => { | ||
| 363 | const plan = planProjectOverridesClear( | ||
| 364 | [ | ||
| 365 | environment(laptop, { | ||
| 366 | [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" }, | ||
| 367 | [t3]: { defaultAutoPull: true }, | ||
| 368 | }), | ||
| 369 | ], | ||
| 370 | [ | ||
| 371 | { environmentId: laptop, projectId: fleet }, | ||
| 372 | { environmentId: laptop, projectId: t3 }, | ||
| 373 | ], | ||
| 374 | ["defaultAutoPull"], | ||
| 375 | ); | ||
| 376 | expect(plan.serverWrites).toEqual([ | ||
| 377 | { | ||
| 378 | environmentId: laptop, | ||
| 379 | label: laptop, | ||
| 380 | patch: { | ||
| 381 | projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: null }, | ||
| 382 | }, | ||
| 383 | }, | ||
| 384 | ]); | ||
| 385 | }); | ||
| 386 | }); | ||
| 387 | |||
| 388 | describe("partial object patches at project scope", () => { | ||
| 389 | it("completes a writing style field patch from the target's effective value", () => { | ||
| 390 | const environmentId = EnvironmentId.make("laptop"); | ||
| 391 | const projectId = ProjectId.make("fleet"); | ||
| 392 | const member = { | ||
| 393 | id: projectId, | ||
| 394 | environmentId, | ||
| 395 | physicalProjectKey: "laptop:/repo", | ||
| 396 | environmentLabel: "Laptop", | ||
| 397 | title: "fleet", | ||
| 398 | workspaceRoot: "/repo", | ||
| 399 | defaultModelSelection: null, | ||
| 400 | scripts: [], | ||
| 401 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 402 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 403 | }; | ||
| 404 | const settings = { | ||
| 405 | ...DEFAULT_SERVER_SETTINGS, | ||
| 406 | projectSettingsOverrides: { | ||
| 407 | [projectId]: { | ||
| 408 | sourceControlWritingStyle: { | ||
| 409 | mode: "custom" as const, | ||
| 410 | customInstructions: "Keep it short.", | ||
| 411 | followChangeRequestTemplates: false, | ||
| 412 | }, | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | }; | ||
| 416 | const plan = planScopedSettingsPatch( | ||
| 417 | { | ||
| 418 | kind: "project", | ||
| 419 | group: {} as never, | ||
| 420 | environmentId: null, | ||
| 421 | label: "fleet", | ||
| 422 | members: [member as never], | ||
| 423 | environmentIds: [environmentId], | ||
| 424 | }, | ||
| 425 | [ | ||
| 426 | { | ||
| 427 | environmentId, | ||
| 428 | label: "Laptop", | ||
| 429 | connection: { phase: "connected" }, | ||
| 430 | serverConfig: { | ||
| 431 | settings, | ||
| 432 | environment: { capabilities: { projectSettingsOverrides: true } }, | ||
| 433 | }, | ||
| 434 | }, | ||
| 435 | ], | ||
| 436 | { sourceControlWritingStyle: { customInstructions: "Be terse." } }, | ||
| 437 | ); | ||
| 438 | expect(plan.serverWrites[0]?.patch).toEqual({ | ||
| 439 | projectSettingsOverrides: { | ||
| 440 | [projectId]: { | ||
| 441 | sourceControlWritingStyle: { | ||
| 442 | mode: "custom", | ||
| 443 | customInstructions: "Be terse.", | ||
| 444 | followChangeRequestTemplates: false, | ||
| 445 | }, | ||
| 446 | }, | ||
| 447 | }, | ||
| 448 | }); | ||
| 449 | }); | ||
| 450 | }); | ||
apps/web/src/components/settings/scopedSettings.ts created+361| ... | @@ -0,0 +1,361 @@ | ||
| 1 | import { | ||
| 2 | ClientSettingsSchema, | ||
| 3 | type ClientSettingsPatch, | ||
| 4 | type EnvironmentId, | ||
| 5 | PROJECT_SCOPED_SERVER_SETTING_KEYS, | ||
| 6 | type ProjectId, | ||
| 7 | type ProjectScopedServerSettingKey, | ||
| 8 | type ProjectSettingsOverrides, | ||
| 9 | ServerSettings, | ||
| 10 | type ServerSettingsPatch, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 13 | import { | ||
| 14 | clearProjectSettingsOverrides, | ||
| 15 | resolveProjectSettings, | ||
| 16 | type ProjectSettingSource, | ||
| 17 | } from "@t3tools/shared/projectSettings"; | ||
| 18 | import * as Equal from "effect/Equal"; | ||
| 19 | |||
| 20 | import type { ResolvedSettingsScope } from "./settingsScope"; | ||
| 21 | |||
| 22 | export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; | ||
| 23 | |||
| 24 | interface ScopedSettingsEnvironment { | ||
| 25 | readonly environmentId: EnvironmentId; | ||
| 26 | readonly label: string; | ||
| 27 | readonly connection: { readonly phase: EnvironmentConnectionPhase }; | ||
| 28 | readonly serverConfig: { | ||
| 29 | readonly settings: ServerSettings; | ||
| 30 | readonly environment?: { | ||
| 31 | readonly capabilities: { readonly projectSettingsOverrides?: boolean | undefined }; | ||
| 32 | }; | ||
| 33 | } | null; | ||
| 34 | } | ||
| 35 | |||
| 36 | const SERVER_KEYS = new Set<string>(Object.keys(ServerSettings.fields)); | ||
| 37 | const CLIENT_KEYS = new Set<string>(Object.keys(ClientSettingsSchema.fields)); | ||
| 38 | const PROJECT_SCOPED_KEYS = new Set<string>(PROJECT_SCOPED_SERVER_SETTING_KEYS); | ||
| 39 | |||
| 40 | export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey { | ||
| 41 | return PROJECT_SCOPED_KEYS.has(key); | ||
| 42 | } | ||
| 43 | |||
| 44 | function isPlainObject(value: unknown): value is Record<string, unknown> { | ||
| 45 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 46 | } | ||
| 47 | |||
| 48 | /** The representative supplies display values, never the set of write targets. */ | ||
| 49 | export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>( | ||
| 50 | scope: ResolvedSettingsScope, | ||
| 51 | available: readonly T[], | ||
| 52 | primaryEnvironmentId: EnvironmentId | null, | ||
| 53 | ) { | ||
| 54 | const selectedIds = new Set(scope.environmentIds); | ||
| 55 | const environments = available.filter((environment) => | ||
| 56 | selectedIds.has(environment.environmentId), | ||
| 57 | ); | ||
| 58 | const connectedEnvironments = environments.filter( | ||
| 59 | (environment) => | ||
| 60 | environment.connection.phase === "connected" && environment.serverConfig !== null, | ||
| 61 | ); | ||
| 62 | const environment = | ||
| 63 | connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ?? | ||
| 64 | connectedEnvironments[0] ?? | ||
| 65 | null; | ||
| 66 | return { environments, connectedEnvironments, environment }; | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * One (environment, project) pair the scope writes to, with that project's | ||
| 71 | * effective settings. Environment scopes have no member and read the | ||
| 72 | * environment settings directly. | ||
| 73 | */ | ||
| 74 | export interface ScopedSettingsTarget { | ||
| 75 | readonly environmentId: EnvironmentId; | ||
| 76 | /** The environment's label; a project is the same project on every environment. */ | ||
| 77 | readonly label: string; | ||
| 78 | readonly projectId: ProjectId | null; | ||
| 79 | readonly settings: ServerSettings; | ||
| 80 | readonly sources: Readonly<Record<ProjectScopedServerSettingKey, ProjectSettingSource>>; | ||
| 81 | } | ||
| 82 | |||
| 83 | /** Effective settings per connected target: members at project scope, environments otherwise. */ | ||
| 84 | export function resolveScopedSettingsTargets( | ||
| 85 | scope: ResolvedSettingsScope, | ||
| 86 | connectedEnvironments: readonly ScopedSettingsEnvironment[], | ||
| 87 | ): readonly ScopedSettingsTarget[] { | ||
| 88 | const byId = new Map( | ||
| 89 | connectedEnvironments.map((environment) => [environment.environmentId, environment]), | ||
| 90 | ); | ||
| 91 | if (scope.kind === "project" || scope.kind === "checkout") { | ||
| 92 | return scope.members.flatMap((member) => { | ||
| 93 | const environment = byId.get(member.environmentId); | ||
| 94 | if (!environment?.serverConfig) return []; | ||
| 95 | const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id); | ||
| 96 | return [ | ||
| 97 | { | ||
| 98 | environmentId: member.environmentId, | ||
| 99 | label: environment.label, | ||
| 100 | projectId: member.id, | ||
| 101 | settings: resolved.settings, | ||
| 102 | sources: resolved.sources, | ||
| 103 | }, | ||
| 104 | ]; | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | return connectedEnvironments.flatMap((environment) => | ||
| 108 | environment.serverConfig | ||
| 109 | ? [ | ||
| 110 | { | ||
| 111 | environmentId: environment.environmentId, | ||
| 112 | label: environment.label, | ||
| 113 | projectId: null, | ||
| 114 | settings: environment.serverConfig.settings, | ||
| 115 | sources: resolveProjectSettings(environment.serverConfig.settings, null).sources, | ||
| 116 | }, | ||
| 117 | ] | ||
| 118 | : [], | ||
| 119 | ); | ||
| 120 | } | ||
| 121 | |||
| 122 | export function scopedSettingsAreMixed( | ||
| 123 | targets: readonly Pick<ScopedSettingsTarget, "settings">[], | ||
| 124 | keys: readonly (keyof ServerSettings)[], | ||
| 125 | ): boolean { | ||
| 126 | const first = targets[0]; | ||
| 127 | return ( | ||
| 128 | first !== undefined && | ||
| 129 | targets.some((candidate) => | ||
| 130 | keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])), | ||
| 131 | ) | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | export type ScopedSettingSource = ProjectSettingSource | "mixed"; | ||
| 136 | |||
| 137 | /** Whether the keys are overridden on every target, inherited on every target, or split. */ | ||
| 138 | export function scopedSettingsSource( | ||
| 139 | targets: readonly Pick<ScopedSettingsTarget, "sources">[], | ||
| 140 | keys: readonly (keyof ServerSettings)[], | ||
| 141 | ): ScopedSettingSource { | ||
| 142 | const scoped = keys.filter(isProjectScopedSettingKey); | ||
| 143 | if (scoped.length === 0 || targets.length === 0) return "environment"; | ||
| 144 | const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key]))); | ||
| 145 | return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment"; | ||
| 146 | } | ||
| 147 | |||
| 148 | interface ScopedServerWrite { | ||
| 149 | readonly environmentId: EnvironmentId; | ||
| 150 | readonly label: string; | ||
| 151 | readonly patch: ServerSettingsPatch; | ||
| 152 | } | ||
| 153 | |||
| 154 | function projectOverrideWrites( | ||
| 155 | scope: Extract<ResolvedSettingsScope, { kind: "project" | "checkout" }>, | ||
| 156 | environments: readonly ScopedSettingsEnvironment[], | ||
| 157 | update: ( | ||
| 158 | current: ProjectSettingsOverrides, | ||
| 159 | settings: ServerSettings, | ||
| 160 | projectId: ProjectId, | ||
| 161 | ) => ProjectSettingsOverrides | null, | ||
| 162 | ): ScopedServerWrite[] { | ||
| 163 | const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); | ||
| 164 | const writes = new Map<EnvironmentId, ScopedServerWrite>(); | ||
| 165 | for (const member of scope.members) { | ||
| 166 | const environment = byId.get(member.environmentId); | ||
| 167 | if ( | ||
| 168 | !environment?.serverConfig || | ||
| 169 | environment.connection.phase !== "connected" || | ||
| 170 | environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true | ||
| 171 | ) { | ||
| 172 | continue; | ||
| 173 | } | ||
| 174 | const settings = environment.serverConfig.settings; | ||
| 175 | const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); | ||
| 176 | const existing = writes.get(member.environmentId); | ||
| 177 | writes.set(member.environmentId, { | ||
| 178 | environmentId: member.environmentId, | ||
| 179 | label: environment.label, | ||
| 180 | patch: { | ||
| 181 | projectSettingsOverrides: { | ||
| 182 | ...existing?.patch.projectSettingsOverrides, | ||
| 183 | [member.id]: entry, | ||
| 184 | }, | ||
| 185 | }, | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | return [...writes.values()]; | ||
| 189 | } | ||
| 190 | |||
| 191 | /** | ||
| 192 | * Environment scopes write the patch to every connected environment; project | ||
| 193 | * and checkout scopes write the scopable keys into each member's override | ||
| 194 | * entry on its environment. Client keys always persist locally. | ||
| 195 | */ | ||
| 196 | export function planScopedSettingsPatch( | ||
| 197 | scope: ResolvedSettingsScope, | ||
| 198 | environments: readonly ScopedSettingsEnvironment[], | ||
| 199 | patch: ScopedSettingsPatch, | ||
| 200 | ) { | ||
| 201 | const clientPatch = Object.fromEntries( | ||
| 202 | Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key)), | ||
| 203 | ) as ClientSettingsPatch; | ||
| 204 | const serverPatch = Object.fromEntries( | ||
| 205 | Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), | ||
| 206 | ) as ServerSettingsPatch; | ||
| 207 | const serverKeys = Object.keys(serverPatch); | ||
| 208 | const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); | ||
| 209 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; | ||
| 210 | const unscopableKeys = isProjectScope | ||
| 211 | ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) | ||
| 212 | : []; | ||
| 213 | const serverWrites: ScopedServerWrite[] = | ||
| 214 | serverKeys.length === 0 | ||
| 215 | ? [] | ||
| 216 | : isProjectScope | ||
| 217 | ? unscopableKeys.length > 0 | ||
| 218 | ? [] | ||
| 219 | : projectOverrideWrites(scope, environments, (current, settings, projectId) => { | ||
| 220 | // Object-valued keys arrive as partial patches (the writing style | ||
| 221 | // rows send one field); an override entry stores the whole value, | ||
| 222 | // so complete the patch from the target's effective value. | ||
| 223 | const effective = resolveProjectSettings(settings, projectId).settings; | ||
| 224 | const next: Record<string, unknown> = { ...current }; | ||
| 225 | for (const [key, value] of Object.entries(serverPatch)) { | ||
| 226 | const base = effective[key as keyof ServerSettings]; | ||
| 227 | next[key] = | ||
| 228 | isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value; | ||
| 229 | } | ||
| 230 | return next as ProjectSettingsOverrides; | ||
| 231 | }) | ||
| 232 | : scope.kind === "all" || scope.kind === "environment" | ||
| 233 | ? connectedEnvironments.map((environment) => ({ | ||
| 234 | environmentId: environment.environmentId, | ||
| 235 | label: environment.label, | ||
| 236 | patch: serverPatch, | ||
| 237 | })) | ||
| 238 | : []; | ||
| 239 | const hasClientWrite = Object.keys(clientPatch).length > 0; | ||
| 240 | const hasWrite = hasClientWrite || serverWrites.length > 0; | ||
| 241 | const unavailableReason = | ||
| 242 | hasWrite || Object.keys(patch).length === 0 | ||
| 243 | ? null | ||
| 244 | : scope.kind === "unavailable" | ||
| 245 | ? scope.message | ||
| 246 | : unscopableKeys.length > 0 | ||
| 247 | ? "This setting is environment-wide and cannot be overridden by a project." | ||
| 248 | : isProjectScope | ||
| 249 | ? "Connect the selected checkouts, or update their environments, to save a project override." | ||
| 250 | : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`; | ||
| 251 | return { clientPatch, hasClientWrite, serverWrites, unavailableReason }; | ||
| 252 | } | ||
| 253 | |||
| 254 | /** Remove the keys' project overrides so each member inherits its environment value again. */ | ||
| 255 | export function planScopedSettingsClear( | ||
| 256 | scope: ResolvedSettingsScope, | ||
| 257 | environments: readonly ScopedSettingsEnvironment[], | ||
| 258 | keys: readonly ProjectScopedServerSettingKey[], | ||
| 259 | ) { | ||
| 260 | const serverWrites = | ||
| 261 | scope.kind === "project" || scope.kind === "checkout" | ||
| 262 | ? projectOverrideWrites(scope, environments, (_current, settings, projectId) => | ||
| 263 | clearProjectSettingsOverrides(settings, projectId, keys), | ||
| 264 | ) | ||
| 265 | : []; | ||
| 266 | return { | ||
| 267 | clientPatch: {} as ClientSettingsPatch, | ||
| 268 | hasClientWrite: false, | ||
| 269 | serverWrites, | ||
| 270 | unavailableReason: | ||
| 271 | serverWrites.length > 0 | ||
| 272 | ? null | ||
| 273 | : "Connect the selected checkouts, or update their environments, to reset this override.", | ||
| 274 | }; | ||
| 275 | } | ||
| 276 | |||
| 277 | export interface ProjectOverrideEntry { | ||
| 278 | readonly environmentId: EnvironmentId; | ||
| 279 | readonly projectId: ProjectId; | ||
| 280 | } | ||
| 281 | |||
| 282 | /** | ||
| 283 | * The projects on the selected environments that override `keys`. An | ||
| 284 | * environment edit leaves these untouched, so the row can name them and | ||
| 285 | * offer to clear them. | ||
| 286 | */ | ||
| 287 | export function listProjectOverrides( | ||
| 288 | environments: readonly ScopedSettingsEnvironment[], | ||
| 289 | keys: readonly (keyof ServerSettings)[], | ||
| 290 | ): readonly ProjectOverrideEntry[] { | ||
| 291 | const scoped = keys.filter(isProjectScopedSettingKey); | ||
| 292 | if (scoped.length === 0) return []; | ||
| 293 | return environments.flatMap((environment) => { | ||
| 294 | const overrides = environment.serverConfig?.settings.projectSettingsOverrides; | ||
| 295 | if (!overrides) return []; | ||
| 296 | return Object.entries(overrides).flatMap(([projectId, entry]) => | ||
| 297 | scoped.some((key) => Object.hasOwn(entry, key)) | ||
| 298 | ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }] | ||
| 299 | : [], | ||
| 300 | ); | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | /** Drop `keys` from the named project entries so they follow the environment again. */ | ||
| 305 | export function planProjectOverridesClear( | ||
| 306 | environments: readonly ScopedSettingsEnvironment[], | ||
| 307 | entries: readonly ProjectOverrideEntry[], | ||
| 308 | keys: readonly ProjectScopedServerSettingKey[], | ||
| 309 | ) { | ||
| 310 | const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); | ||
| 311 | const writes = new Map<EnvironmentId, ScopedServerWrite>(); | ||
| 312 | for (const { environmentId, projectId } of entries) { | ||
| 313 | const environment = byId.get(environmentId); | ||
| 314 | if (!environment?.serverConfig || environment.connection.phase !== "connected") continue; | ||
| 315 | const settings = environment.serverConfig.settings; | ||
| 316 | const existing = writes.get(environmentId); | ||
| 317 | writes.set(environmentId, { | ||
| 318 | environmentId, | ||
| 319 | label: environment.label, | ||
| 320 | patch: { | ||
| 321 | projectSettingsOverrides: { | ||
| 322 | ...existing?.patch.projectSettingsOverrides, | ||
| 323 | [projectId]: clearProjectSettingsOverrides(settings, projectId, keys), | ||
| 324 | }, | ||
| 325 | }, | ||
| 326 | }); | ||
| 327 | } | ||
| 328 | const serverWrites = [...writes.values()]; | ||
| 329 | return { | ||
| 330 | clientPatch: {} as ClientSettingsPatch, | ||
| 331 | hasClientWrite: false, | ||
| 332 | serverWrites, | ||
| 333 | unavailableReason: | ||
| 334 | serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.", | ||
| 335 | }; | ||
| 336 | } | ||
| 337 | |||
| 338 | /** Wait for every target so a failed environment does not hide successful or later writes. */ | ||
| 339 | export async function persistScopedSettingsPatch( | ||
| 340 | plan: ReturnType<typeof planScopedSettingsPatch>, | ||
| 341 | persistServer: (input: { | ||
| 342 | environmentId: EnvironmentId; | ||
| 343 | input: { patch: ServerSettingsPatch }; | ||
| 344 | }) => Promise<{ readonly _tag: "Success" | "Failure" }>, | ||
| 345 | persistClient: (patch: ClientSettingsPatch) => void, | ||
| 346 | ) { | ||
| 347 | if (plan.hasClientWrite) persistClient(plan.clientPatch); | ||
| 348 | const results = await Promise.allSettled( | ||
| 349 | plan.serverWrites.map(({ environmentId, patch }) => | ||
| 350 | persistServer({ environmentId, input: { patch } }), | ||
| 351 | ), | ||
| 352 | ); | ||
| 353 | const failedEnvironments = plan.serverWrites.filter((_, index) => { | ||
| 354 | const result = results[index]; | ||
| 355 | return result?.status !== "fulfilled" || result.value._tag === "Failure"; | ||
| 356 | }); | ||
| 357 | return { | ||
| 358 | failedEnvironments, | ||
| 359 | savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length, | ||
| 360 | }; | ||
| 361 | } | ||
apps/web/src/components/settings/settingsLayout.tsx+166-24| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { InfoIcon, Undo2Icon } from "lucide-react"; | 1 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 2 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; | ||
| 3 | import * as Equal from "effect/Equal"; | ||
| 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 4 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 5 | import { |
| 4 | createContext, | 6 | createContext, |
| ... | @@ -19,6 +21,21 @@ import { cn } from "../../lib/utils"; | ... | @@ -19,6 +21,21 @@ import { cn } from "../../lib/utils"; |
| 19 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; | 21 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; |
| 20 | import { Button } from "../ui/button"; | 22 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 23 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 24 | import { useOptionalSettingsScope } from "./SettingsScopeContext"; | ||
| 25 | import { | ||
| 26 | isProjectScopedSettingKey, | ||
| 27 | listProjectOverrides, | ||
| 28 | scopedSettingsAreMixed, | ||
| 29 | scopedSettingsSource, | ||
| 30 | } from "./scopedSettings"; | ||
| 31 | import { useClearProjectOverrides, useClearScopedSettings } from "./useScopedSettings"; | ||
| 32 | import { | ||
| 33 | SettingInheritance, | ||
| 34 | type SettingInheritanceState, | ||
| 35 | type SettingOverridingProject, | ||
| 36 | } from "./SettingInheritance"; | ||
| 37 | |||
| 38 | const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = []; | ||
| 22 | 39 | ||
| 23 | declare module "@tanstack/react-router" { | 40 | declare module "@tanstack/react-router" { |
| 24 | interface HistoryState { | 41 | interface HistoryState { |
| ... | @@ -253,8 +270,11 @@ export function SettingsRow({ | ... | @@ -253,8 +270,11 @@ export function SettingsRow({ |
| 253 | description, | 270 | description, |
| 254 | status, | 271 | status, |
| 255 | resetAction, | 272 | resetAction, |
| 273 | onResetOverride, | ||
| 256 | control, | 274 | control, |
| 257 | serverScoped = false, | 275 | serverScoped = false, |
| 276 | settingKeys = EMPTY_SETTING_KEYS, | ||
| 277 | mixed: mixedOverride, | ||
| 258 | children, | 278 | children, |
| 259 | className, | 279 | className, |
| 260 | ...rowProps | 280 | ...rowProps |
| ... | @@ -263,37 +283,152 @@ export function SettingsRow({ | ... | @@ -263,37 +283,152 @@ export function SettingsRow({ |
| 263 | description?: ReactNode; | 283 | description?: ReactNode; |
| 264 | status?: ReactNode; | 284 | status?: ReactNode; |
| 265 | resetAction?: ReactNode; | 285 | resetAction?: ReactNode; |
| 286 | /** Replaces the default override clear for rows with side effects beyond the settings key. */ | ||
| 287 | onResetOverride?: () => void; | ||
| 266 | control?: ReactNode; | 288 | control?: ReactNode; |
| 267 | serverScoped?: boolean; | 289 | serverScoped?: boolean; |
| 290 | settingKeys?: readonly (keyof ServerSettings)[]; | ||
| 291 | mixed?: boolean; | ||
| 268 | children?: ReactNode; | 292 | children?: ReactNode; |
| 269 | }) { | 293 | }) { |
| 270 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); | 294 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); |
| 271 | const primarySettingsAvailable = usePrimarySettingsAvailable(); | 295 | const primarySettingsAvailable = usePrimarySettingsAvailable(); |
| 272 | const unavailable = serverScoped && !primarySettingsAvailable; | 296 | const context = useOptionalSettingsScope(); |
| 273 | const renderedReset = unavailable ? null : resetAction; | 297 | const clearOverrides = useClearScopedSettings(); |
| 298 | const clearProjectOverrides = useClearProjectOverrides(); | ||
| 299 | const isProjectScope = | ||
| 300 | context !== null && (context.scope.kind === "project" || context.scope.kind === "checkout"); | ||
| 301 | const scopedKeys = settingKeys.filter(isProjectScopedSettingKey); | ||
| 302 | // A project scope can only edit keys that support overrides; the rest stay | ||
| 303 | // visible so the user sees the inherited value, but cannot change it here. | ||
| 304 | const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0; | ||
| 305 | const mixed = | ||
| 306 | mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys)); | ||
| 307 | const source = | ||
| 308 | context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null; | ||
| 309 | const unavailable = | ||
| 310 | serverScoped && | ||
| 311 | !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable); | ||
| 312 | const inheritedFrom = | ||
| 313 | source === "environment" && context?.scope.environmentIds.length === 1 | ||
| 314 | ? (context.environments.find( | ||
| 315 | (environment) => environment.environmentId === context.scope.environmentIds[0], | ||
| 316 | )?.label ?? "environment") | ||
| 317 | : "environment"; | ||
| 318 | const environmentSettingsById = useMemo( | ||
| 319 | () => | ||
| 320 | new Map( | ||
| 321 | (context?.connectedEnvironments ?? []).flatMap((environment) => | ||
| 322 | environment.serverConfig | ||
| 323 | ? [[environment.environmentId, environment.serverConfig.settings] as const] | ||
| 324 | : [], | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | [context?.connectedEnvironments], | ||
| 328 | ); | ||
| 329 | // At environment scope, projects with their own value keep it when the | ||
| 330 | // environment default changes; the chain names them and can reset them. | ||
| 331 | const overridingProjects = useMemo((): SettingOverridingProject[] => { | ||
| 332 | if (context === null || isProjectScope || scopedKeys.length === 0) return []; | ||
| 333 | return listProjectOverrides(context.connectedEnvironments, scopedKeys).flatMap((entry) => { | ||
| 334 | const group = context.groups.find((candidate) => | ||
| 335 | candidate.memberProjects.some( | ||
| 336 | (member) => member.environmentId === entry.environmentId && member.id === entry.projectId, | ||
| 337 | ), | ||
| 338 | ); | ||
| 339 | if (!group) return []; | ||
| 340 | return [ | ||
| 341 | { | ||
| 342 | ...entry, | ||
| 343 | label: group.displayName, | ||
| 344 | open: () => | ||
| 345 | context.selectScope({ | ||
| 346 | project: group.projectKey, | ||
| 347 | ...(context.search.machine ? { machine: context.search.machine } : {}), | ||
| 348 | }), | ||
| 349 | }, | ||
| 350 | ]; | ||
| 351 | }); | ||
| 352 | }, [context, isProjectScope, scopedKeys]); | ||
| 353 | const renderedReset = unavailable ? null : isProjectScope && scopedKeys.length > 0 ? ( | ||
| 354 | source === "project" || source === "mixed" ? ( | ||
| 355 | <SettingResetButton | ||
| 356 | label={typeof title === "string" ? title : "override"} | ||
| 357 | tooltip="Reset to inherited value" | ||
| 358 | onClick={() => (onResetOverride ? onResetOverride() : clearOverrides(scopedKeys))} | ||
| 359 | /> | ||
| 360 | ) : null | ||
| 361 | ) : ( | ||
| 362 | resetAction | ||
| 363 | ); | ||
| 364 | const inertControl = (message: string) => ( | ||
| 365 | <Tooltip> | ||
| 366 | <TooltipTrigger | ||
| 367 | render={ | ||
| 368 | // Focusable so keyboard users can still reach the explanation. | ||
| 369 | <span | ||
| 370 | tabIndex={0} | ||
| 371 | className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto" | ||
| 372 | /> | ||
| 373 | } | ||
| 374 | > | ||
| 375 | <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto"> | ||
| 376 | {control} | ||
| 377 | </div> | ||
| 378 | </TooltipTrigger> | ||
| 379 | <TooltipPopup side="top" className="max-w-72"> | ||
| 380 | {message} | ||
| 381 | </TooltipPopup> | ||
| 382 | </Tooltip> | ||
| 383 | ); | ||
| 384 | // A mixed selection keeps the real control with "Mixed" as its placeholder | ||
| 385 | // (the multi-selection inspector convention): the popover shows who has | ||
| 386 | // what, and picking a value applies it to every target. | ||
| 274 | const renderedControl = | 387 | const renderedControl = |
| 275 | unavailable && control ? ( | 388 | unavailable && control |
| 276 | <Tooltip> | 389 | ? inertControl( |
| 277 | <TooltipTrigger | 390 | context |
| 278 | render={ | 391 | ? "Reconnect the selected environment to change this setting." |
| 279 | // Focusable so keyboard users can still reach the explanation. | 392 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, |
| 280 | <span | 393 | ) |
| 281 | tabIndex={0} | 394 | : environmentWide && control |
| 282 | className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto" | 395 | ? inertControl("Environment-wide setting. Select an environment to change it.") |
| 283 | /> | 396 | : control; |
| 284 | } | 397 | // Server rows get an indicator beside the title that opens the resolution |
| 285 | > | 398 | // chain per target at every scope; client rows keep a plain status only. |
| 286 | <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto"> | 399 | const customized = |
| 287 | {control} | 400 | context !== null && |
| 288 | </div> | 401 | settingKeys.some((key) => |
| 289 | </TooltipTrigger> | 402 | context.targets.some((candidate) => { |
| 290 | <TooltipPopup side="top" className="max-w-72"> | 403 | const environmentSettings = environmentSettingsById.get(candidate.environmentId); |
| 291 | {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} | 404 | return ( |
| 292 | </TooltipPopup> | 405 | environmentSettings !== undefined && |
| 293 | </Tooltip> | 406 | !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key]) |
| 294 | ) : ( | 407 | ); |
| 295 | control | 408 | }), |
| 296 | ); | 409 | ); |
| 410 | const inheritance: { state: SettingInheritanceState; summary: string } = mixed | ||
| 411 | ? { state: "mixed", summary: "Mixed across selected environments" } | ||
| 412 | : source === "project" | ||
| 413 | ? { state: "overridden", summary: "Overridden for this project" } | ||
| 414 | : source === "environment" && scopedKeys.length > 0 | ||
| 415 | ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } | ||
| 416 | : customized | ||
| 417 | ? { state: "environment", summary: "Set on the environment" } | ||
| 418 | : { state: "default", summary: "Built-in default" }; | ||
| 419 | const renderedInheritance = | ||
| 420 | context && serverScoped && settingKeys.length > 0 ? ( | ||
| 421 | <SettingInheritance | ||
| 422 | state={inheritance.state} | ||
| 423 | summary={inheritance.summary} | ||
| 424 | targets={context.targets} | ||
| 425 | environments={context.connectedEnvironments} | ||
| 426 | keys={settingKeys} | ||
| 427 | overridingProjects={overridingProjects} | ||
| 428 | onClearOverrides={(entries) => clearProjectOverrides(entries, scopedKeys)} | ||
| 429 | /> | ||
| 430 | ) : null; | ||
| 431 | const renderedStatus = status; | ||
| 297 | 432 | ||
| 298 | return ( | 433 | return ( |
| 299 | <div | 434 | <div |
| ... | @@ -311,6 +446,11 @@ export function SettingsRow({ | ... | @@ -311,6 +446,11 @@ export function SettingsRow({ |
| 311 | <div className="min-w-0 flex-1 space-y-1"> | 446 | <div className="min-w-0 flex-1 space-y-1"> |
| 312 | <div className="flex min-h-5 items-center gap-1.5"> | 447 | <div className="flex min-h-5 items-center gap-1.5"> |
| 313 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3> | 448 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3> |
| 449 | {renderedInheritance ? ( | ||
| 450 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | ||
| 451 | {renderedInheritance} | ||
| 452 | </span> | ||
| 453 | ) : null} | ||
| 314 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | 454 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> |
| 315 | {renderedReset} | 455 | {renderedReset} |
| 316 | </span> | 456 | </span> |
| ... | @@ -320,7 +460,9 @@ export function SettingsRow({ | ... | @@ -320,7 +460,9 @@ export function SettingsRow({ |
| 320 | {description} | 460 | {description} |
| 321 | </p> | 461 | </p> |
| 322 | ) : null} | 462 | ) : null} |
| 323 | {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null} | 463 | {renderedStatus ? ( |
| 464 | <div className="pt-0.5 text-xs text-muted-foreground">{renderedStatus}</div> | ||
| 465 | ) : null} | ||
| 324 | </div> | 466 | </div> |
| 325 | {renderedControl ? ( | 467 | {renderedControl ? ( |
| 326 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 468 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
apps/web/src/components/settings/settingsScope.test.ts+8-19| ... | @@ -60,19 +60,12 @@ function group( | ... | @@ -60,19 +60,12 @@ function group( |
| 60 | const groups = [group("t3code", [first, second, third]), group("other", [other])]; | 60 | const groups = [group("t3code", [first, second, third]), group("other", [other])]; |
| 61 | 61 | ||
| 62 | describe("settings scope search", () => { | 62 | describe("settings scope search", () => { |
| 63 | it.each(["device", "all"] as const)( | 63 | it("ignores the retired scope key from older links", () => { |
| 64 | "clears narrower targets for an explicit %s selection", | 64 | expect(validateSettingsScopeSearch({ scope: "device", project: "t3code" })).toEqual({ |
| 65 | (scope) => { | 65 | project: "t3code", |
| 66 | expect( | 66 | }); |
| 67 | validateSettingsScopeSearch({ | 67 | expect(validateSettingsScopeSearch({ scope: "all" })).toEqual({}); |
| 68 | scope, | 68 | }); |
| 69 | project: "t3code", | ||
| 70 | machine: laptopId, | ||
| 71 | checkout: first.physicalProjectKey, | ||
| 72 | }), | ||
| 73 | ).toEqual({ scope }); | ||
| 74 | }, | ||
| 75 | ); | ||
| 76 | 69 | ||
| 77 | it("retains legacy project and machine links without inventing an explicit broad scope", () => { | 70 | it("retains legacy project and machine links without inventing an explicit broad scope", () => { |
| 78 | expect( | 71 | expect( |
| ... | @@ -96,15 +89,11 @@ describe("settings scope search", () => { | ... | @@ -96,15 +89,11 @@ describe("settings scope search", () => { |
| 96 | }); | 89 | }); |
| 97 | 90 | ||
| 98 | describe("settings scope resolution", () => { | 91 | describe("settings scope resolution", () => { |
| 99 | it("distinguishes this device from the default aggregate target", () => { | 92 | it("defaults to every environment with no project", () => { |
| 100 | expect(resolveSettingsScope({}, groups, environments)).toMatchObject({ | 93 | expect(resolveSettingsScope({}, groups, environments)).toMatchObject({ |
| 101 | kind: "all", | 94 | kind: "all", |
| 102 | environmentIds: [laptopId, serverId], | ||
| 103 | }); | ||
| 104 | expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({ | ||
| 105 | kind: "device", | ||
| 106 | members: [], | 95 | members: [], |
| 107 | environmentIds: [], | 96 | environmentIds: [laptopId, serverId], |
| 108 | }); | 97 | }); |
| 109 | }); | 98 | }); |
| 110 | 99 |
apps/web/src/components/settings/settingsScope.ts+8-15| ... | @@ -6,8 +6,13 @@ import type { | ... | @@ -6,8 +6,13 @@ import type { |
| 6 | } from "../../sidebarProjectGrouping"; | 6 | } from "../../sidebarProjectGrouping"; |
| 7 | import type { EnvironmentPresentation } from "../../state/environments"; | 7 | import type { EnvironmentPresentation } from "../../state/environments"; |
| 8 | 8 | ||
| 9 | /** | ||
| 10 | * Two axes. `machine` narrows the environment axis (absent = all | ||
| 11 | * environments); `project` and `checkout` narrow the project axis (absent = | ||
| 12 | * environment defaults). Device-local preferences are not a scope: they | ||
| 13 | * render regardless of the selection because they never touch a server. | ||
| 14 | */ | ||
| 9 | export interface SettingsScopeSearch { | 15 | export interface SettingsScopeSearch { |
| 10 | scope?: "device" | "all" | undefined; | ||
| 11 | project?: string | undefined; | 16 | project?: string | undefined; |
| 12 | machine?: string | undefined; | 17 | machine?: string | undefined; |
| 13 | checkout?: string | undefined; | 18 | checkout?: string | undefined; |
| ... | @@ -21,7 +26,7 @@ type ScopeTargets = { | ... | @@ -21,7 +26,7 @@ type ScopeTargets = { |
| 21 | 26 | ||
| 22 | export type ResolvedSettingsScope = ScopeTargets & | 27 | export type ResolvedSettingsScope = ScopeTargets & |
| 23 | ( | 28 | ( |
| 24 | | { kind: "device" | "all" } | 29 | | { kind: "all" } |
| 25 | | { kind: "environment"; environmentId: EnvironmentId } | 30 | | { kind: "environment"; environmentId: EnvironmentId } |
| 26 | | { | 31 | | { |
| 27 | kind: "project"; | 32 | kind: "project"; |
| ... | @@ -41,9 +46,8 @@ export type ResolvedSettingsScope = ScopeTargets & | ... | @@ -41,9 +46,8 @@ export type ResolvedSettingsScope = ScopeTargets & |
| 41 | } | 46 | } |
| 42 | ); | 47 | ); |
| 43 | 48 | ||
| 44 | /** Explicit broad targets replace narrower selections; stale IDs remain visible to the resolver. */ | 49 | /** Stale IDs remain visible to the resolver so a removed target reads as unavailable, not as "all". */ |
| 45 | export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch { | 50 | export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch { |
| 46 | if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope }; | ||
| 47 | const stringValue = (value: unknown) => | 51 | const stringValue = (value: unknown) => |
| 48 | typeof value === "string" && value.trim().length > 0 ? value : undefined; | 52 | typeof value === "string" && value.trim().length > 0 ? value : undefined; |
| 49 | const project = stringValue(raw.project); | 53 | const project = stringValue(raw.project); |
| ... | @@ -74,17 +78,6 @@ export function resolveSettingsScope( | ... | @@ -74,17 +78,6 @@ export function resolveSettingsScope( |
| 74 | environmentIds: [], | 78 | environmentIds: [], |
| 75 | }); | 79 | }); |
| 76 | 80 | ||
| 77 | if (search.scope === "device") { | ||
| 78 | return { kind: "device", label: "This device", members: [], environmentIds: [] }; | ||
| 79 | } | ||
| 80 | if (search.scope === "all") { | ||
| 81 | return { | ||
| 82 | kind: "all", | ||
| 83 | label: "All environments", | ||
| 84 | members: [], | ||
| 85 | environmentIds: environments.map((environment) => environment.environmentId), | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | if (search.checkout && !search.project) { | 81 | if (search.checkout && !search.project) { |
| 89 | return unavailable("project-required", "Select a project to choose one of its checkouts."); | 82 | return unavailable("project-required", "Select a project to choose one of its checkouts."); |
| 90 | } | 83 | } |
apps/web/src/components/settings/settingsScopeAxis.test.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | environmentAxisValue, | ||
| 6 | projectAxisValue, | ||
| 7 | selectEnvironmentAxis, | ||
| 8 | selectProjectAxis, | ||
| 9 | settingsScopeEnvironmentLabel, | ||
| 10 | } from "./settingsScopeAxis"; | ||
| 11 | |||
| 12 | const first = { | ||
| 13 | environmentId: EnvironmentId.make("first"), | ||
| 14 | label: "Development", | ||
| 15 | displayUrl: "https://first.example.com", | ||
| 16 | }; | ||
| 17 | const second = { | ||
| 18 | environmentId: EnvironmentId.make("second"), | ||
| 19 | label: "Development", | ||
| 20 | displayUrl: "https://second.example.com", | ||
| 21 | }; | ||
| 22 | |||
| 23 | describe("settings scope environment labels", () => { | ||
| 24 | it("distinguishes same-name environments by address", () => { | ||
| 25 | const environments = [first, second]; | ||
| 26 | expect( | ||
| 27 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 28 | ).toEqual([ | ||
| 29 | "Development · https://first.example.com", | ||
| 30 | "Development · https://second.example.com", | ||
| 31 | ]); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("falls back to environment IDs when duplicate names have no display URL", () => { | ||
| 35 | const environments = [first, second].map((environment) => ({ | ||
| 36 | ...environment, | ||
| 37 | displayUrl: null, | ||
| 38 | })); | ||
| 39 | expect( | ||
| 40 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 41 | ).toEqual(["Development · first", "Development · second"]); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("keeps unique names compact and removes disambiguation after a rename", () => { | ||
| 45 | expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); | ||
| 46 | expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( | ||
| 47 | "Development", | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | }); | ||
| 51 | |||
| 52 | describe("settings scope axes", () => { | ||
| 53 | it("maps each axis to its search key and back", () => { | ||
| 54 | expect(projectAxisValue({})).toBe("all"); | ||
| 55 | expect(projectAxisValue({ project: "app" })).toBe("app"); | ||
| 56 | expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({ | ||
| 57 | project: "app", | ||
| 58 | machine: "second", | ||
| 59 | }); | ||
| 60 | expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({ | ||
| 61 | machine: "second", | ||
| 62 | }); | ||
| 63 | expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({ | ||
| 64 | project: "app", | ||
| 65 | machine: "first", | ||
| 66 | }); | ||
| 67 | expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({ | ||
| 68 | project: "app", | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("drops a checkout narrowing from older links when either axis changes", () => { | ||
| 73 | const checkout = { project: "app", checkout: "app@first", machine: "first" }; | ||
| 74 | expect(selectEnvironmentAxis(checkout, "second")).toEqual({ | ||
| 75 | project: "app", | ||
| 76 | machine: "second", | ||
| 77 | }); | ||
| 78 | expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" }); | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | describe("environmentAxisValue", () => { | ||
| 83 | it("shows the checkout's environment for a legacy checkout link", () => { | ||
| 84 | expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop"); | ||
| 85 | expect(environmentAxisValue({ project: "p" }, null)).toBe("all"); | ||
| 86 | expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); | ||
| 87 | }); | ||
| 88 | }); | ||
apps/web/src/components/settings/settingsScopeAxis.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 2 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 3 | |||
| 4 | type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">; | ||
| 5 | |||
| 6 | export function settingsScopeEnvironmentLabel( | ||
| 7 | environment: ScopeEnvironment, | ||
| 8 | environments: readonly ScopeEnvironment[], | ||
| 9 | ) { | ||
| 10 | const duplicate = environments.some( | ||
| 11 | (other) => | ||
| 12 | other.environmentId !== environment.environmentId && other.label === environment.label, | ||
| 13 | ); | ||
| 14 | return duplicate | ||
| 15 | ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}` | ||
| 16 | : environment.label; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const ALL_ENVIRONMENTS_VALUE = "all"; | ||
| 20 | export const ALL_PROJECTS_VALUE = "all"; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * The environment axis: `all` or an environment id. A legacy checkout link | ||
| 24 | * without `machine` still names one environment, which the resolver supplies. | ||
| 25 | */ | ||
| 26 | export function environmentAxisValue( | ||
| 27 | search: SettingsScopeSearch, | ||
| 28 | resolvedEnvironmentId?: string | null, | ||
| 29 | ): string { | ||
| 30 | return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** The project axis: `all` or a project key. */ | ||
| 34 | export function projectAxisValue(search: SettingsScopeSearch): string { | ||
| 35 | return search.project ?? ALL_PROJECTS_VALUE; | ||
| 36 | } | ||
| 37 | |||
| 38 | /** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */ | ||
| 39 | export function selectEnvironmentAxis( | ||
| 40 | search: SettingsScopeSearch, | ||
| 41 | value: string, | ||
| 42 | ): SettingsScopeSearch { | ||
| 43 | const next: SettingsScopeSearch = {}; | ||
| 44 | if (search.project) next.project = search.project; | ||
| 45 | if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value; | ||
| 46 | return next; | ||
| 47 | } | ||
| 48 | |||
| 49 | /** Choosing a project keeps the environment axis. */ | ||
| 50 | export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch { | ||
| 51 | const next: SettingsScopeSearch = {}; | ||
| 52 | if (value !== ALL_PROJECTS_VALUE) next.project = value; | ||
| 53 | if (search.machine) next.machine = search.machine; | ||
| 54 | return next; | ||
| 55 | } | ||
apps/web/src/components/settings/settingsScopeNavigation.test.ts created+247| ... | @@ -0,0 +1,247 @@ | ||
| 1 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | createMemoryHistory, | ||
| 4 | createRootRoute, | ||
| 5 | createRoute, | ||
| 6 | createRouter, | ||
| 7 | redirect, | ||
| 8 | } from "@tanstack/react-router"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { resolveSettingsScope } from "./settingsScope"; | ||
| 12 | import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation"; | ||
| 13 | |||
| 14 | const checkoutSearch = { | ||
| 15 | project: "repository:t3code", | ||
| 16 | machine: "remote-server", | ||
| 17 | checkout: "remote-server:/home/user/T3 Code", | ||
| 18 | }; | ||
| 19 | |||
| 20 | function createSettingsRouter(initialEntry = "/settings/general") { | ||
| 21 | const root = createRootRoute(); | ||
| 22 | const settings = createRoute({ | ||
| 23 | getParentRoute: () => root, | ||
| 24 | path: "settings", | ||
| 25 | validateSearch: validateSettingsRouteSearch, | ||
| 26 | search: { middlewares: [retainSettingsScope] }, | ||
| 27 | beforeLoad: ({ location }) => { | ||
| 28 | if (location.pathname === "/settings") { | ||
| 29 | throw redirect({ to: "/settings/general", replace: true }); | ||
| 30 | } | ||
| 31 | }, | ||
| 32 | }); | ||
| 33 | const general = createRoute({ getParentRoute: () => settings, path: "general" }); | ||
| 34 | const projects = createRoute({ getParentRoute: () => settings, path: "projects" }); | ||
| 35 | const integrations = createRoute({ getParentRoute: () => settings, path: "integrations" }); | ||
| 36 | const sourceControl = createRoute({ getParentRoute: () => settings, path: "source-control" }); | ||
| 37 | const providers = createRoute({ | ||
| 38 | getParentRoute: () => settings, | ||
| 39 | path: "providers", | ||
| 40 | validateSearch: (raw: Record<string, unknown>) => ({ | ||
| 41 | ...(typeof raw.environmentId === "string" && raw.environmentId.trim() | ||
| 42 | ? { environmentId: EnvironmentId.make(raw.environmentId) } | ||
| 43 | : {}), | ||
| 44 | ...(typeof raw.instanceId === "string" && raw.instanceId.trim() | ||
| 45 | ? { instanceId: ProviderInstanceId.make(raw.instanceId) } | ||
| 46 | : {}), | ||
| 47 | }), | ||
| 48 | }); | ||
| 49 | const legacyProject = createRoute({ | ||
| 50 | getParentRoute: () => root, | ||
| 51 | path: "projects/$projectKey", | ||
| 52 | beforeLoad: ({ params }) => { | ||
| 53 | throw redirect({ | ||
| 54 | to: "/settings/projects", | ||
| 55 | search: { project: params.projectKey, machine: undefined }, | ||
| 56 | replace: true, | ||
| 57 | }); | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | return createRouter({ | ||
| 61 | routeTree: root.addChildren([ | ||
| 62 | settings.addChildren([general, projects, integrations, sourceControl, providers]), | ||
| 63 | legacyProject, | ||
| 64 | ]), | ||
| 65 | history: createMemoryHistory({ initialEntries: [initialEntry] }), | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | |||
| 69 | describe("settings scope navigation", () => { | ||
| 70 | it("replaces the default scope with an explicit environment, then replaces it with a project", async () => { | ||
| 71 | const router = createSettingsRouter(); | ||
| 72 | await router.load(); | ||
| 73 | await router.navigate({ | ||
| 74 | to: "/settings/general", | ||
| 75 | search: { machine: "remote-server" }, | ||
| 76 | hash: "", | ||
| 77 | }); | ||
| 78 | expect(router.state.location.search).toEqual({ machine: "remote-server" }); | ||
| 79 | await router.navigate({ to: "/settings/projects", search: { project: "another-project" } }); | ||
| 80 | expect(router.state.location.search).toEqual({ project: "another-project" }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("clears a checkout when selecting all environments and all projects", async () => { | ||
| 84 | const router = createSettingsRouter(); | ||
| 85 | await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" }); | ||
| 86 | // The scope selects send every axis explicitly so "all" does not read as "unchanged". | ||
| 87 | await router.navigate({ | ||
| 88 | to: "/settings/general", | ||
| 89 | search: { project: undefined, machine: undefined, checkout: undefined }, | ||
| 90 | hash: "", | ||
| 91 | }); | ||
| 92 | expect(router.state.location.search).toEqual({}); | ||
| 93 | expect(router.state.location.hash).toBe(""); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("preserves the checkout through category and settings-search navigation", async () => { | ||
| 97 | const router = createSettingsRouter(); | ||
| 98 | await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "new-threads" }); | ||
| 99 | await router.navigate({ to: "/settings/integrations", hash: "", replace: true }); | ||
| 100 | expect(router.state.location.search).toEqual(checkoutSearch); | ||
| 101 | expect(router.state.location.hash).toBe(""); | ||
| 102 | await router.navigate({ to: "/settings/source-control", hash: "source-control-writing-style" }); | ||
| 103 | expect(router.state.location.search).toEqual(checkoutSearch); | ||
| 104 | expect(router.state.location.hash).toBe("source-control-writing-style"); | ||
| 105 | await router.navigate({ to: "/settings/projects", hash: "project-defaults" }); | ||
| 106 | expect(router.state.location.search).toEqual(checkoutSearch); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it.each(["/settings/projects", "/settings/integrations", "/settings/source-control"] as const)( | ||
| 110 | "keeps %s when regrouping or selecting a target from the shared settings layout", | ||
| 111 | async (to) => { | ||
| 112 | const router = createSettingsRouter(); | ||
| 113 | await router.navigate({ to, search: checkoutSearch }); | ||
| 114 | |||
| 115 | const regroupedCheckout = { ...checkoutSearch, project: "separate:t3code" }; | ||
| 116 | await router.navigate({ | ||
| 117 | from: "/settings", | ||
| 118 | to: router.state.location.pathname, | ||
| 119 | search: () => regroupedCheckout, | ||
| 120 | replace: true, | ||
| 121 | hashScrollIntoView: false, | ||
| 122 | }); | ||
| 123 | expect(router.state.location.pathname).toBe(to); | ||
| 124 | expect(router.state.location.search).toEqual(regroupedCheckout); | ||
| 125 | expect(router.state.redirect).toBeUndefined(); | ||
| 126 | |||
| 127 | await router.navigate({ | ||
| 128 | from: "/settings", | ||
| 129 | to: router.state.location.pathname, | ||
| 130 | search: () => ({ machine: "another-server" }), | ||
| 131 | hash: "", | ||
| 132 | resetScroll: false, | ||
| 133 | }); | ||
| 134 | expect(router.state.location.pathname).toBe(to); | ||
| 135 | expect(router.state.location.search).toEqual({ machine: "another-server" }); | ||
| 136 | expect(router.state.location.hash).toBe(""); | ||
| 137 | |||
| 138 | const selectedCheckout = { | ||
| 139 | project: "another-project", | ||
| 140 | machine: "another-server", | ||
| 141 | checkout: "another-server:/home/user/Another checkout", | ||
| 142 | }; | ||
| 143 | await router.navigate({ | ||
| 144 | from: "/settings", | ||
| 145 | to: router.state.location.pathname, | ||
| 146 | search: () => selectedCheckout, | ||
| 147 | hash: "requested-setting", | ||
| 148 | }); | ||
| 149 | expect(router.state.location.pathname).toBe(to); | ||
| 150 | expect(router.state.location.search).toEqual(selectedCheckout); | ||
| 151 | expect(router.state.location.hash).toBe("requested-setting"); | ||
| 152 | }, | ||
| 153 | ); | ||
| 154 | |||
| 155 | it("honors an explicit provider environment and drops its instance on category navigation", async () => { | ||
| 156 | const router = createSettingsRouter(); | ||
| 157 | await router.navigate({ to: "/settings/general", search: checkoutSearch }); | ||
| 158 | await router.navigate({ | ||
| 159 | to: "/settings/providers", | ||
| 160 | search: { | ||
| 161 | environmentId: EnvironmentId.make("provider-server"), | ||
| 162 | instanceId: ProviderInstanceId.make("codex-work"), | ||
| 163 | }, | ||
| 164 | }); | ||
| 165 | expect(router.state.location.search).toEqual({ | ||
| 166 | machine: "provider-server", | ||
| 167 | environmentId: "provider-server", | ||
| 168 | instanceId: "codex-work", | ||
| 169 | }); | ||
| 170 | await router.navigate({ to: "/settings/general", hash: "" }); | ||
| 171 | expect(router.state.location.search).toEqual({ machine: "provider-server" }); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("preserves the environment from an initially loaded legacy provider URL", async () => { | ||
| 175 | const router = createSettingsRouter( | ||
| 176 | "/settings/providers?environmentId=provider-server&instanceId=codex-work", | ||
| 177 | ); | ||
| 178 | await router.load(); | ||
| 179 | await router.navigate({ to: "/settings/general" }); | ||
| 180 | expect(router.state.location.search).toEqual({ machine: "provider-server" }); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("retains an explicit unavailable target rather than reviving the previous environment", async () => { | ||
| 184 | const router = createSettingsRouter(); | ||
| 185 | await router.navigate({ to: "/settings/general", search: { machine: "online" } }); | ||
| 186 | await router.navigate({ to: "/settings/general", search: { machine: "removed" } }); | ||
| 187 | expect(router.state.location.search).toEqual({ machine: "removed" }); | ||
| 188 | expect( | ||
| 189 | resolveSettingsScope( | ||
| 190 | router.state.location.search, | ||
| 191 | [], | ||
| 192 | [{ environmentId: EnvironmentId.make("online"), label: "Online" }], | ||
| 193 | ), | ||
| 194 | ).toMatchObject({ | ||
| 195 | kind: "unavailable", | ||
| 196 | reason: "environment-missing", | ||
| 197 | environmentIds: [], | ||
| 198 | }); | ||
| 199 | }); | ||
| 200 | |||
| 201 | it("respects explicit clearing keys instead of restoring the previous checkout", async () => { | ||
| 202 | const router = createSettingsRouter(); | ||
| 203 | await router.navigate({ to: "/settings/general", search: checkoutSearch }); | ||
| 204 | await router.navigate({ | ||
| 205 | to: "/settings/projects", | ||
| 206 | search: { project: "different-project", machine: undefined }, | ||
| 207 | }); | ||
| 208 | expect(router.state.location.search).toEqual({ project: "different-project" }); | ||
| 209 | }); | ||
| 210 | |||
| 211 | it("preserves escaped checkout identifiers across reload and browser history", async () => { | ||
| 212 | const router = createSettingsRouter(); | ||
| 213 | await router.navigate({ to: "/settings/general", search: checkoutSearch }); | ||
| 214 | const checkoutHref = router.state.location.href; | ||
| 215 | await router.navigate({ to: "/settings/general", search: { machine: "another" } }); | ||
| 216 | router.history.back(); | ||
| 217 | await router.load(); | ||
| 218 | expect(router.state.location.search).toEqual(checkoutSearch); | ||
| 219 | const reloaded = createSettingsRouter(checkoutHref); | ||
| 220 | await reloaded.load(); | ||
| 221 | expect(reloaded.state.location.search).toEqual(checkoutSearch); | ||
| 222 | await reloaded.navigate({ to: "/settings/integrations", hash: "agent-browser-access" }); | ||
| 223 | expect(reloaded.state.location.search).toEqual(checkoutSearch); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it("redirects legacy project links without carrying the prior checkout scope", async () => { | ||
| 227 | const router = createSettingsRouter(); | ||
| 228 | await router.navigate({ to: "/settings/general", search: checkoutSearch }); | ||
| 229 | await router.navigate({ | ||
| 230 | to: "/projects/$projectKey", | ||
| 231 | params: { projectKey: "legacy-project" }, | ||
| 232 | }); | ||
| 233 | expect(router.state.redirect).not.toBeUndefined(); | ||
| 234 | await router.navigate(router.state.redirect!.options); | ||
| 235 | expect(router.state.location.pathname).toBe("/settings/projects"); | ||
| 236 | expect(router.state.location.search).toEqual({ project: "legacy-project" }); | ||
| 237 | }); | ||
| 238 | |||
| 239 | it("keeps scope through the settings index redirect", async () => { | ||
| 240 | const router = createSettingsRouter(); | ||
| 241 | await router.navigate({ to: "/settings", search: { machine: "remote-server" } }); | ||
| 242 | expect(router.state.redirect).not.toBeUndefined(); | ||
| 243 | await router.navigate(router.state.redirect!.options); | ||
| 244 | expect(router.state.location.pathname).toBe("/settings/general"); | ||
| 245 | expect(router.state.location.search).toEqual({ machine: "remote-server" }); | ||
| 246 | }); | ||
| 247 | }); | ||
apps/web/src/components/settings/settingsScopeNavigation.ts created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import type { SearchMiddleware } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { validateSettingsScopeSearch, type SettingsScopeSearch } from "./settingsScope"; | ||
| 4 | |||
| 5 | /** Accept legacy provider links without replacing an explicit settings scope. */ | ||
| 6 | export function validateSettingsRouteSearch(raw: Record<string, unknown>) { | ||
| 7 | return validateSettingsScopeSearch( | ||
| 8 | typeof raw.environmentId === "string" && raw.machine === undefined && raw.project === undefined | ||
| 9 | ? { ...raw, machine: raw.environmentId } | ||
| 10 | : raw, | ||
| 11 | ); | ||
| 12 | } | ||
| 13 | |||
| 14 | const SCOPE_KEYS = [ | ||
| 15 | "project", | ||
| 16 | "machine", | ||
| 17 | "checkout", | ||
| 18 | ] as const satisfies readonly (keyof SettingsScopeSearch)[]; | ||
| 19 | const TARGET_INPUT_KEYS = [...SCOPE_KEYS, "environmentId"]; | ||
| 20 | |||
| 21 | /** | ||
| 22 | * Category links keep the target, while an explicit target replaces the entire | ||
| 23 | * previous selection. `environmentId` is the legacy provider deep-link target. | ||
| 24 | */ | ||
| 25 | export const retainSettingsScope: SearchMiddleware<SettingsScopeSearch> = ({ search, next }) => { | ||
| 26 | const result = next(search); | ||
| 27 | if (TARGET_INPUT_KEYS.some((key) => Object.hasOwn(result, key))) return result; | ||
| 28 | const previousScope = Object.fromEntries( | ||
| 29 | SCOPE_KEYS.filter((key) => search[key] !== undefined).map((key) => [key, search[key]]), | ||
| 30 | ); | ||
| 31 | return { ...previousScope, ...result }; | ||
| 32 | }; | ||
apps/web/src/components/settings/settingsSearch.test.ts+197-2| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | filterAvailableSettingsSearchItems, | 5 | filterAvailableSettingsSearchItems, |
| 6 | getSettingsSearchTargetScope, | ||
| 7 | getThreadAutoSettlementSearchAvailability, | ||
| 8 | isSettingsOverviewVisible, | ||
| 9 | isSettingsSearchScopeAvailable, | ||
| 5 | searchableSetting, | 10 | searchableSetting, |
| 6 | searchSettings, | 11 | searchSettings, |
| 7 | SETTINGS_SEARCH_ITEMS, | 12 | SETTINGS_SEARCH_ITEMS, |
| ... | @@ -143,7 +148,7 @@ describe("searchSettings", () => { | ... | @@ -143,7 +148,7 @@ describe("searchSettings", () => { |
| 143 | it("hides settings whose controls are unavailable", () => { | 148 | it("hides settings whose controls are unavailable", () => { |
| 144 | const available = filterAvailableSettingsSearchItems({ | 149 | const available = filterAvailableSettingsSearchItems({ |
| 145 | hasCloudPublicConfig: false, | 150 | hasCloudPublicConfig: false, |
| 146 | hasPrimaryEnvironment: false, | 151 | hasEnvironment: false, |
| 147 | hasProviderSettingsEnvironment: false, | 152 | hasProviderSettingsEnvironment: false, |
| 148 | canManageLocalBackend: false, | 153 | canManageLocalBackend: false, |
| 149 | isWslSettingsRowVisible: false, | 154 | isWslSettingsRowVisible: false, |
| ... | @@ -171,7 +176,7 @@ describe("searchSettings", () => { | ... | @@ -171,7 +176,7 @@ describe("searchSettings", () => { |
| 171 | it("shows automatic settlement settings when the server supports them", () => { | 176 | it("shows automatic settlement settings when the server supports them", () => { |
| 172 | const available = filterAvailableSettingsSearchItems({ | 177 | const available = filterAvailableSettingsSearchItems({ |
| 173 | hasCloudPublicConfig: false, | 178 | hasCloudPublicConfig: false, |
| 174 | hasPrimaryEnvironment: false, | 179 | hasEnvironment: false, |
| 175 | hasProviderSettingsEnvironment: false, | 180 | hasProviderSettingsEnvironment: false, |
| 176 | canManageLocalBackend: false, | 181 | canManageLocalBackend: false, |
| 177 | isWslSettingsRowVisible: false, | 182 | isWslSettingsRowVisible: false, |
| ... | @@ -256,4 +261,194 @@ describe("searchSettings", () => { | ... | @@ -256,4 +261,194 @@ describe("searchSettings", () => { |
| 256 | targetId: "browser-profiles", | 261 | targetId: "browser-profiles", |
| 257 | }); | 262 | }); |
| 258 | }); | 263 | }); |
| 264 | |||
| 265 | it.each([ | ||
| 266 | ["default model", "default-model", "/settings/general"], | ||
| 267 | ["new threads", "new-threads", "/settings/general"], | ||
| 268 | ["agent browser access", "agent-browser-access", "/settings/integrations"], | ||
| 269 | ["automatically pull", "automatic-pull", "/settings/source-control"], | ||
| 270 | ["actions", "project-actions", "/settings/projects"], | ||
| 271 | ["project overview", "project-overview", "/settings/projects"], | ||
| 272 | ])("routes %s to its owning category", (query, id, to) => { | ||
| 273 | expect(searchSettings(query)[0]).toMatchObject({ id, to }); | ||
| 274 | }); | ||
| 275 | |||
| 276 | it("keeps environment settings discoverable without a primary environment", () => { | ||
| 277 | const available = filterAvailableSettingsSearchItems({ | ||
| 278 | hasCloudPublicConfig: false, | ||
| 279 | hasEnvironment: true, | ||
| 280 | hasProviderSettingsEnvironment: true, | ||
| 281 | canManageLocalBackend: false, | ||
| 282 | isWslSettingsRowVisible: false, | ||
| 283 | hasThreadAutoSettlement: true, | ||
| 284 | }); | ||
| 285 | expect(searchSettings("writing style", available)[0]?.id).toBe("source-control-writing-style"); | ||
| 286 | expect(searchSettings("auto-settle", available)).toHaveLength(3); | ||
| 287 | }); | ||
| 288 | }); | ||
| 289 | |||
| 290 | describe("settings search targets", () => { | ||
| 291 | it.each([ | ||
| 292 | "auto-settle-inactive-threads", | ||
| 293 | "auto-settle-merged-threads", | ||
| 294 | "days-before-auto-settle", | ||
| 295 | ])("retains the capability requirement for %s", (targetId) => { | ||
| 296 | expect(getSettingsSearchTargetScope(targetId)).toMatchObject({ | ||
| 297 | scope: "project-defaults", | ||
| 298 | requiresThreadAutoSettlement: true, | ||
| 299 | }); | ||
| 300 | }); | ||
| 301 | |||
| 302 | it("treats device-local rows as reachable from every selection", () => { | ||
| 303 | const setting = getSettingsSearchTargetScope("time-format")!; | ||
| 304 | expect(setting).toEqual({ title: "Time format", scope: null }); | ||
| 305 | expect(isSettingsSearchScopeAvailable(setting.scope, "project")).toBe(true); | ||
| 306 | expect(isSettingsSearchScopeAvailable(setting.scope, "all")).toBe(true); | ||
| 307 | expect(getSettingsSearchTargetScope("appearance")).toMatchObject({ scope: null }); | ||
| 308 | expect(getSettingsSearchTargetScope("missing-setting")).toBeNull(); | ||
| 309 | }); | ||
| 310 | |||
| 311 | it.each(["all", "environment", "project", "checkout"] as const)( | ||
| 312 | "makes browser access editable at the %s scope", | ||
| 313 | (kind) => { | ||
| 314 | const setting = getSettingsSearchTargetScope("agent-browser-access")!; | ||
| 315 | expect(isSettingsSearchScopeAvailable(setting.scope, kind)).toBe(true); | ||
| 316 | expect(isSettingsSearchScopeAvailable(setting.scope, "unavailable")).toBe(false); | ||
| 317 | }, | ||
| 318 | ); | ||
| 319 | |||
| 320 | it("lets project-scopable rows resolve at every server-backed scope", () => { | ||
| 321 | const model = getSettingsSearchTargetScope("text-generation-model")!; | ||
| 322 | expect(isSettingsSearchScopeAvailable(model.scope, "all")).toBe(true); | ||
| 323 | expect(isSettingsSearchScopeAvailable(model.scope, "environment")).toBe(true); | ||
| 324 | expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true); | ||
| 325 | }); | ||
| 326 | |||
| 327 | it("reaches source control discovery and git fetch interval from the default scope", () => { | ||
| 328 | for (const id of ["source-control", "git-fetch-interval"]) { | ||
| 329 | const item = getSettingsSearchTargetScope(id)!; | ||
| 330 | expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true); | ||
| 331 | expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true); | ||
| 332 | expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(false); | ||
| 333 | } | ||
| 334 | }); | ||
| 335 | |||
| 336 | it("keeps environment-wide settings out of project scopes", () => { | ||
| 337 | const updates = getSettingsSearchTargetScope("provider-update-checks")!; | ||
| 338 | expect(updates.scope).toBe("environment-defaults"); | ||
| 339 | expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true); | ||
| 340 | expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true); | ||
| 341 | expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false); | ||
| 342 | const streaming = getSettingsSearchTargetScope("legacy-token-streaming")!; | ||
| 343 | expect(streaming.scope).toBe("project-defaults"); | ||
| 344 | expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); | ||
| 345 | for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { | ||
| 346 | expect(getSettingsSearchTargetScope(id)!.scope).toBeNull(); | ||
| 347 | } | ||
| 348 | }); | ||
| 349 | }); | ||
| 350 | |||
| 351 | describe("auto-settlement search availability", () => { | ||
| 352 | function environment(id: string, { connected = true, loaded = true, supported = true } = {}) { | ||
| 353 | return { | ||
| 354 | environmentId: EnvironmentId.make(id), | ||
| 355 | connection: { phase: connected ? ("connected" as const) : ("offline" as const) }, | ||
| 356 | serverConfig: loaded | ||
| 357 | ? { environment: { capabilities: { threadAutoSettlement: supported } } } | ||
| 358 | : null, | ||
| 359 | }; | ||
| 360 | } | ||
| 361 | |||
| 362 | const capable = environment("capable"); | ||
| 363 | const unsupported = environment("unsupported", { supported: false }); | ||
| 364 | const offline = environment("offline", { connected: false }); | ||
| 365 | const loading = environment("loading", { loaded: false }); | ||
| 366 | const environments = [capable, unsupported, offline, loading]; | ||
| 367 | |||
| 368 | it("keeps results discoverable when one connected environment supports them", () => { | ||
| 369 | const availability = getThreadAutoSettlementSearchAvailability(environments); | ||
| 370 | expect(availability.eligibleEnvironmentIds).toEqual([capable.environmentId]); | ||
| 371 | const items = filterAvailableSettingsSearchItems({ | ||
| 372 | hasCloudPublicConfig: false, | ||
| 373 | hasEnvironment: true, | ||
| 374 | hasProviderSettingsEnvironment: true, | ||
| 375 | canManageLocalBackend: false, | ||
| 376 | isWslSettingsRowVisible: false, | ||
| 377 | hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0, | ||
| 378 | }); | ||
| 379 | expect(searchSettings("auto-settle", items).map((item) => item.id)).toEqual([ | ||
| 380 | "auto-settle-inactive-threads", | ||
| 381 | "auto-settle-merged-threads", | ||
| 382 | "days-before-auto-settle", | ||
| 383 | ]); | ||
| 384 | }); | ||
| 385 | |||
| 386 | it("offers only capable environments when an aggregate has mixed capabilities", () => { | ||
| 387 | expect( | ||
| 388 | getThreadAutoSettlementSearchAvailability(environments, { | ||
| 389 | kind: "all", | ||
| 390 | environmentIds: environments.map((entry) => entry.environmentId), | ||
| 391 | }), | ||
| 392 | ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false }); | ||
| 393 | }); | ||
| 394 | |||
| 395 | it("allows a capable named environment regardless of other environments' capabilities", () => { | ||
| 396 | expect( | ||
| 397 | getThreadAutoSettlementSearchAvailability(environments, { | ||
| 398 | kind: "environment", | ||
| 399 | environmentIds: [capable.environmentId], | ||
| 400 | }).isTargetAvailable, | ||
| 401 | ).toBe(true); | ||
| 402 | }); | ||
| 403 | |||
| 404 | it.each([unsupported, offline, loading])( | ||
| 405 | "does not render the target on $environmentId or silently fall back", | ||
| 406 | (selected) => { | ||
| 407 | expect( | ||
| 408 | getThreadAutoSettlementSearchAvailability(environments, { | ||
| 409 | kind: "environment", | ||
| 410 | environmentIds: [selected.environmentId], | ||
| 411 | }), | ||
| 412 | ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false }); | ||
| 413 | }, | ||
| 414 | ); | ||
| 415 | |||
| 416 | it("offers a capable environment instead of a dead target at an unavailable scope", () => { | ||
| 417 | expect( | ||
| 418 | getThreadAutoSettlementSearchAvailability(environments, { | ||
| 419 | kind: "unavailable", | ||
| 420 | environmentIds: [capable.environmentId], | ||
| 421 | }), | ||
| 422 | ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false }); | ||
| 423 | }); | ||
| 424 | |||
| 425 | it("ignores offline and unloaded targets when all connected targets support the setting", () => { | ||
| 426 | const selected = [capable, offline, loading]; | ||
| 427 | expect( | ||
| 428 | getThreadAutoSettlementSearchAvailability(selected, { | ||
| 429 | kind: "all", | ||
| 430 | environmentIds: selected.map((entry) => entry.environmentId), | ||
| 431 | }).isTargetAvailable, | ||
| 432 | ).toBe(true); | ||
| 433 | }); | ||
| 434 | |||
| 435 | it("offers no unavailable environments when none can render the setting", () => { | ||
| 436 | const selected = [unsupported, offline, loading]; | ||
| 437 | expect( | ||
| 438 | getThreadAutoSettlementSearchAvailability(selected, { | ||
| 439 | kind: "all", | ||
| 440 | environmentIds: selected.map((entry) => entry.environmentId), | ||
| 441 | }), | ||
| 442 | ).toEqual({ eligibleEnvironmentIds: [], isTargetAvailable: false }); | ||
| 443 | expect(getThreadAutoSettlementSearchAvailability([]).eligibleEnvironmentIds).toEqual([]); | ||
| 444 | }); | ||
| 445 | }); | ||
| 446 | |||
| 447 | describe("settings sidebar scope", () => { | ||
| 448 | it("shows Overview only for project and checkout targets", () => { | ||
| 449 | expect(isSettingsOverviewVisible({})).toBe(false); | ||
| 450 | expect(isSettingsOverviewVisible({ machine: "remote" })).toBe(false); | ||
| 451 | expect(isSettingsOverviewVisible({ project: "project" })).toBe(true); | ||
| 452 | expect(isSettingsOverviewVisible({ project: "project", checkout: "checkout" })).toBe(true); | ||
| 453 | }); | ||
| 259 | }); | 454 | }); |
apps/web/src/components/settings/settingsSearch.ts+187-14| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { isElectron } from "~/env"; | 1 | import { isElectron } from "~/env"; |
| 2 | import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; | 2 | import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 5 | import { | ||
| 6 | validateSettingsScopeSearch, | ||
| 7 | type ResolvedSettingsScope, | ||
| 8 | type SettingsScopeSearch, | ||
| 9 | } from "./settingsScope"; | ||
| 3 | 10 | ||
| 4 | export type SettingsPath = | 11 | export type SettingsPath = |
| 5 | | "/settings/projects" | 12 | | "/settings/projects" |
| ... | @@ -13,6 +20,19 @@ export type SettingsPath = | ... | @@ -13,6 +20,19 @@ export type SettingsPath = |
| 13 | | "/settings/connections" | 20 | | "/settings/connections" |
| 14 | | "/settings/archived"; | 21 | | "/settings/archived"; |
| 15 | 22 | ||
| 23 | /** | ||
| 24 | * Where a setting can be edited. Device-local rows have no scope: they render | ||
| 25 | * at every selection. `project-defaults` rows accept project overrides, so | ||
| 26 | * they are reachable from any server-backed selection. | ||
| 27 | */ | ||
| 28 | export type SettingsSearchScope = | ||
| 29 | | "environment" | ||
| 30 | | "environment-defaults" | ||
| 31 | | "project-defaults" | ||
| 32 | | "project" | ||
| 33 | | "checkout" | ||
| 34 | | "connections"; | ||
| 35 | |||
| 16 | export interface SettingsSearchItem { | 36 | export interface SettingsSearchItem { |
| 17 | readonly id: string; | 37 | readonly id: string; |
| 18 | readonly title: string; | 38 | readonly title: string; |
| ... | @@ -20,6 +40,7 @@ export interface SettingsSearchItem { | ... | @@ -20,6 +40,7 @@ export interface SettingsSearchItem { |
| 20 | readonly targetId?: string; | 40 | readonly targetId?: string; |
| 21 | /** Descriptions, option labels, and aliases people may remember instead of the title. */ | 41 | /** Descriptions, option labels, and aliases people may remember instead of the title. */ |
| 22 | readonly searchTerms?: ReadonlyArray<string>; | 42 | readonly searchTerms?: ReadonlyArray<string>; |
| 43 | readonly scope?: SettingsSearchScope; | ||
| 23 | // Its row only renders in the desktop app, so a browser result would land on | 44 | // Its row only renders in the desktop app, so a browser result would land on |
| 24 | // an anchor that isn't there. | 45 | // an anchor that isn't there. |
| 25 | readonly desktopOnly?: boolean; | 46 | readonly desktopOnly?: boolean; |
| ... | @@ -28,7 +49,7 @@ export interface SettingsSearchItem { | ... | @@ -28,7 +49,7 @@ export interface SettingsSearchItem { |
| 28 | // not expose a result that points to a missing anchor. | 49 | // not expose a result that points to a missing anchor. |
| 29 | readonly windowsOnly?: boolean; | 50 | readonly windowsOnly?: boolean; |
| 30 | readonly cloudOnly?: boolean; | 51 | readonly cloudOnly?: boolean; |
| 31 | readonly primaryOnly?: boolean; | 52 | readonly environmentOnly?: boolean; |
| 32 | readonly providerSettingsOnly?: boolean; | 53 | readonly providerSettingsOnly?: boolean; |
| 33 | readonly localBackendManagementOnly?: boolean; | 54 | readonly localBackendManagementOnly?: boolean; |
| 34 | readonly wslAvailableOnly?: boolean; | 55 | readonly wslAvailableOnly?: boolean; |
| ... | @@ -37,7 +58,7 @@ export interface SettingsSearchItem { | ... | @@ -37,7 +58,7 @@ export interface SettingsSearchItem { |
| 37 | 58 | ||
| 38 | export interface SettingsSearchAvailability { | 59 | export interface SettingsSearchAvailability { |
| 39 | readonly hasCloudPublicConfig: boolean; | 60 | readonly hasCloudPublicConfig: boolean; |
| 40 | readonly hasPrimaryEnvironment: boolean; | 61 | readonly hasEnvironment: boolean; |
| 41 | readonly hasProviderSettingsEnvironment: boolean; | 62 | readonly hasProviderSettingsEnvironment: boolean; |
| 42 | readonly canManageLocalBackend: boolean; | 63 | readonly canManageLocalBackend: boolean; |
| 43 | readonly isWslSettingsRowVisible: boolean; | 64 | readonly isWslSettingsRowVisible: boolean; |
| ... | @@ -49,9 +70,9 @@ export interface SettingsSearchAvailability { | ... | @@ -49,9 +70,9 @@ export interface SettingsSearchAvailability { |
| 49 | * subtitles both render from this record, so each label exists once. | 70 | * subtitles both render from this record, so each label exists once. |
| 50 | */ | 71 | */ |
| 51 | export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { | 72 | export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 73 | "/settings/projects": "Project", | ||
| 52 | "/settings/general": "General", | 74 | "/settings/general": "General", |
| 53 | "/settings/appearance": "Appearance", | 75 | "/settings/appearance": "Appearance", |
| 54 | "/settings/projects": "Projects", | ||
| 55 | "/settings/keybindings": "Keybindings", | 76 | "/settings/keybindings": "Keybindings", |
| 56 | "/settings/snap-shot": "SnapShots", | 77 | "/settings/snap-shot": "SnapShots", |
| 57 | "/settings/providers": "Providers", | 78 | "/settings/providers": "Providers", |
| ... | @@ -70,10 +91,22 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -70,10 +91,22 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 70 | { | 91 | { |
| 71 | id: "project-defaults", | 92 | id: "project-defaults", |
| 72 | title: "Project defaults and overrides", | 93 | title: "Project defaults and overrides", |
| 94 | to: "/settings/general", | ||
| 95 | scope: "project-defaults", | ||
| 96 | searchTerms: ["model workspace environments projects inheritance checkout"], | ||
| 97 | }, | ||
| 98 | { | ||
| 99 | id: "project-overview", | ||
| 100 | title: "Project overview", | ||
| 73 | to: "/settings/projects", | 101 | to: "/settings/projects", |
| 74 | searchTerms: [ | 102 | searchTerms: ["name icon emoji image checkout remove delete"], |
| 75 | "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", | 103 | }, |
| 76 | ], | 104 | { |
| 105 | id: "default-model", | ||
| 106 | title: "Default model", | ||
| 107 | to: "/settings/general", | ||
| 108 | scope: "project-defaults", | ||
| 109 | searchTerms: ["new thread project provider reasoning effort"], | ||
| 77 | }, | 110 | }, |
| 78 | { | 111 | { |
| 79 | id: "color-scheme", | 112 | id: "color-scheme", |
| ... | @@ -174,6 +207,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -174,6 +207,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 174 | to: "/settings/general", | 207 | to: "/settings/general", |
| 175 | searchTerms: ["sidebar inactivity days no activity automatically"], | 208 | searchTerms: ["sidebar inactivity days no activity automatically"], |
| 176 | requiresThreadAutoSettlement: true, | 209 | requiresThreadAutoSettlement: true, |
| 210 | scope: "project-defaults", | ||
| 177 | }, | 211 | }, |
| 178 | { | 212 | { |
| 179 | id: "auto-settle-merged-threads", | 213 | id: "auto-settle-merged-threads", |
| ... | @@ -181,6 +215,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -181,6 +215,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 181 | to: "/settings/general", | 215 | to: "/settings/general", |
| 182 | searchTerms: ["pull request merge closed automatically sidebar"], | 216 | searchTerms: ["pull request merge closed automatically sidebar"], |
| 183 | requiresThreadAutoSettlement: true, | 217 | requiresThreadAutoSettlement: true, |
| 218 | scope: "project-defaults", | ||
| 184 | }, | 219 | }, |
| 185 | { | 220 | { |
| 186 | id: "days-before-auto-settle", | 221 | id: "days-before-auto-settle", |
| ... | @@ -189,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -189,6 +224,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 189 | targetId: "auto-settle-inactive-threads", | 224 | targetId: "auto-settle-inactive-threads", |
| 190 | searchTerms: ["thread timeout activity sidebar"], | 225 | searchTerms: ["thread timeout activity sidebar"], |
| 191 | requiresThreadAutoSettlement: true, | 226 | requiresThreadAutoSettlement: true, |
| 227 | scope: "project-defaults", | ||
| 192 | }, | 228 | }, |
| 193 | { | 229 | { |
| 194 | id: "time-format", | 230 | id: "time-format", |
| ... | @@ -231,11 +267,13 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -231,11 +267,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 231 | title: "Provider update checks", | 267 | title: "Provider update checks", |
| 232 | to: "/settings/general", | 268 | to: "/settings/general", |
| 233 | searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], | 269 | searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], |
| 270 | scope: "environment-defaults", | ||
| 234 | }, | 271 | }, |
| 235 | { | 272 | { |
| 236 | id: "continue-threads-after-server-update", | 273 | id: "continue-threads-after-server-update", |
| 237 | title: "Continue threads after restarts", | 274 | title: "Continue threads after restarts", |
| 238 | to: "/settings/general", | 275 | to: "/settings/general", |
| 276 | scope: "project-defaults", | ||
| 239 | searchTerms: [ | 277 | searchTerms: [ |
| 240 | "resume running active interrupted work restart reboot machine crash desktop update automatically", | 278 | "resume running active interrupted work restart reboot machine crash desktop update automatically", |
| 241 | ], | 279 | ], |
| ... | @@ -244,6 +282,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -244,6 +282,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 244 | id: "background-activity", | 282 | id: "background-activity", |
| 245 | title: "Background activity", | 283 | title: "Background activity", |
| 246 | to: "/settings/general", | 284 | to: "/settings/general", |
| 285 | scope: "environment-defaults", | ||
| 247 | searchTerms: [ | 286 | searchTerms: [ |
| 248 | "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy", | 287 | "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy", |
| 249 | ], | 288 | ], |
| ... | @@ -251,19 +290,22 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -251,19 +290,22 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 251 | { | 290 | { |
| 252 | id: "new-threads", | 291 | id: "new-threads", |
| 253 | title: "New threads", | 292 | title: "New threads", |
| 254 | to: "/settings/projects", | 293 | to: "/settings/general", |
| 294 | scope: "project-defaults", | ||
| 255 | searchTerms: ["default workspace mode draft local worktree"], | 295 | searchTerms: ["default workspace mode draft local worktree"], |
| 256 | }, | 296 | }, |
| 257 | { | 297 | { |
| 258 | id: "start-from-origin", | 298 | id: "start-from-origin", |
| 259 | title: "Start from origin", | 299 | title: "Start from origin", |
| 260 | to: "/settings/general", | 300 | to: "/settings/general", |
| 301 | scope: "project-defaults", | ||
| 261 | searchTerms: ["new worktrees latest matching remote branch local"], | 302 | searchTerms: ["new worktrees latest matching remote branch local"], |
| 262 | }, | 303 | }, |
| 263 | { | 304 | { |
| 264 | id: "add-project-starts-in", | 305 | id: "add-project-starts-in", |
| 265 | title: "Add project starts in", | 306 | title: "Add project starts in", |
| 266 | to: "/settings/general", | 307 | to: "/settings/general", |
| 308 | scope: "environment-defaults", | ||
| 267 | searchTerms: ["base directory folder browser path home"], | 309 | searchTerms: ["base directory folder browser path home"], |
| 268 | }, | 310 | }, |
| 269 | { | 311 | { |
| ... | @@ -295,6 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -295,6 +337,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 295 | id: "text-generation-model", | 337 | id: "text-generation-model", |
| 296 | title: "Text generation model", | 338 | title: "Text generation model", |
| 297 | to: "/settings/general", | 339 | to: "/settings/general", |
| 340 | scope: "project-defaults", | ||
| 298 | searchTerms: ["generated thread titles source control content default provider"], | 341 | searchTerms: ["generated thread titles source control content default provider"], |
| 299 | }, | 342 | }, |
| 300 | { | 343 | { |
| ... | @@ -319,6 +362,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -319,6 +362,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 319 | id: "legacy-token-streaming", | 362 | id: "legacy-token-streaming", |
| 320 | title: "Stream token by token (legacy)", | 363 | title: "Stream token by token (legacy)", |
| 321 | to: "/settings/general", | 364 | to: "/settings/general", |
| 365 | scope: "project-defaults", | ||
| 322 | searchTerms: ["response output old compatibility"], | 366 | searchTerms: ["response output old compatibility"], |
| 323 | }, | 367 | }, |
| 324 | { | 368 | { |
| ... | @@ -399,8 +443,9 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -399,8 +443,9 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 399 | { | 443 | { |
| 400 | id: "agent-browser-access", | 444 | id: "agent-browser-access", |
| 401 | title: "Agent browser access", | 445 | title: "Agent browser access", |
| 402 | to: "/settings/projects", | 446 | to: "/settings/integrations", |
| 403 | searchTerms: ["allow open drive preview tools sessions"], | 447 | scope: "project-defaults", |
| 448 | searchTerms: ["allow disable enable open drive preview tools sessions project override"], | ||
| 404 | }, | 449 | }, |
| 405 | { | 450 | { |
| 406 | id: "device-hosts", | 451 | id: "device-hosts", |
| ... | @@ -476,10 +521,25 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -476,10 +521,25 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 476 | to: "/settings/integrations", | 521 | to: "/settings/integrations", |
| 477 | searchTerms: ["agent opens browser pop into view hide"], | 522 | searchTerms: ["agent opens browser pop into view hide"], |
| 478 | }, | 523 | }, |
| 524 | { | ||
| 525 | id: "automatic-pull", | ||
| 526 | title: "Automatically pull", | ||
| 527 | to: "/settings/source-control", | ||
| 528 | scope: "project-defaults", | ||
| 529 | searchTerms: ["auto pull default branch current checkout fast forward upstream"], | ||
| 530 | }, | ||
| 531 | { | ||
| 532 | id: "pull-request-merge-method", | ||
| 533 | title: "Default merge method", | ||
| 534 | to: "/settings/source-control", | ||
| 535 | scope: "project-defaults", | ||
| 536 | searchTerms: ["pull request merge squash rebase last selected"], | ||
| 537 | }, | ||
| 479 | { | 538 | { |
| 480 | id: "source-control", | 539 | id: "source-control", |
| 481 | title: "Source control", | 540 | title: "Source control", |
| 482 | to: "/settings/source-control", | 541 | to: "/settings/source-control", |
| 542 | scope: "environment-defaults", | ||
| 483 | searchTerms: [ | 543 | searchTerms: [ |
| 484 | "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment", | 544 | "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment", |
| 485 | ], | 545 | ], |
| ... | @@ -491,7 +551,8 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -491,7 +551,8 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 491 | searchTerms: [ | 551 | searchTerms: [ |
| 492 | "automatic remote branch refresh background credentials security keys seconds off", | 552 | "automatic remote branch refresh background credentials security keys seconds off", |
| 493 | ], | 553 | ], |
| 494 | primaryOnly: true, | 554 | environmentOnly: true, |
| 555 | scope: "environment-defaults", | ||
| 495 | }, | 556 | }, |
| 496 | { | 557 | { |
| 497 | id: "source-control-writing-style", | 558 | id: "source-control-writing-style", |
| ... | @@ -500,14 +561,14 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -500,14 +561,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 500 | searchTerms: [ | 561 | searchTerms: [ |
| 501 | "repository conventions conventional commits custom instructions change descriptions request titles", | 562 | "repository conventions conventional commits custom instructions change descriptions request titles", |
| 502 | ], | 563 | ], |
| 503 | primaryOnly: true, | 564 | environmentOnly: true, |
| 504 | }, | 565 | }, |
| 505 | { | 566 | { |
| 506 | id: "follow-change-request-templates", | 567 | id: "follow-change-request-templates", |
| 507 | title: "Follow change request templates", | 568 | title: "Follow change request templates", |
| 508 | to: "/settings/source-control", | 569 | to: "/settings/source-control", |
| 509 | searchTerms: ["repository pr pull request description structure"], | 570 | searchTerms: ["repository pr pull request description structure"], |
| 510 | primaryOnly: true, | 571 | environmentOnly: true, |
| 511 | }, | 572 | }, |
| 512 | { | 573 | { |
| 513 | id: "source-control-writer-model", | 574 | id: "source-control-writer-model", |
| ... | @@ -516,7 +577,14 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -516,7 +577,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 516 | searchTerms: [ | 577 | searchTerms: [ |
| 517 | "override generated commit change request pr titles descriptions branch bookmark", | 578 | "override generated commit change request pr titles descriptions branch bookmark", |
| 518 | ], | 579 | ], |
| 519 | primaryOnly: true, | 580 | environmentOnly: true, |
| 581 | scope: "project-defaults", | ||
| 582 | }, | ||
| 583 | { | ||
| 584 | id: "project-actions", | ||
| 585 | title: "Actions", | ||
| 586 | to: "/settings/projects", | ||
| 587 | searchTerms: ["commands scripts setup run dev server checkout worktree t3.json import"], | ||
| 520 | }, | 588 | }, |
| 521 | { | 589 | { |
| 522 | id: "environment-icon", | 590 | id: "environment-icon", |
| ... | @@ -606,6 +674,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"]; | ... | @@ -606,6 +674,111 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"]; |
| 606 | 674 | ||
| 607 | const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const)); | 675 | const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const)); |
| 608 | 676 | ||
| 677 | const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = { | ||
| 678 | "/settings/projects": "project", | ||
| 679 | "/settings/general": null, | ||
| 680 | "/settings/appearance": null, | ||
| 681 | "/settings/snap-shot": null, | ||
| 682 | // Keybindings fan out to the selection; Providers shows the representative | ||
| 683 | // environment at any selection. Neither needs a particular scope to render. | ||
| 684 | "/settings/keybindings": null, | ||
| 685 | "/settings/providers": null, | ||
| 686 | "/settings/integrations": null, | ||
| 687 | "/settings/source-control": "environment-defaults", | ||
| 688 | "/settings/connections": "connections", | ||
| 689 | "/settings/archived": "project-defaults", | ||
| 690 | }; | ||
| 691 | |||
| 692 | /** Search keeps the selected target. A missing row can explain its owning scope instead. */ | ||
| 693 | export function getSettingsSearchTargetScope(targetId: string) { | ||
| 694 | const items: readonly SettingsSearchItem[] = SETTINGS_SEARCH_ITEMS; | ||
| 695 | const item = | ||
| 696 | items.find((candidate) => candidate.id === targetId) ?? | ||
| 697 | items.find((candidate) => candidate.targetId === targetId); | ||
| 698 | return item | ||
| 699 | ? { | ||
| 700 | title: item.title, | ||
| 701 | scope: item.scope ?? SETTINGS_CATEGORY_SCOPES[item.to], | ||
| 702 | ...(item.requiresThreadAutoSettlement ? { requiresThreadAutoSettlement: true } : {}), | ||
| 703 | } | ||
| 704 | : null; | ||
| 705 | } | ||
| 706 | |||
| 707 | interface AutoSettlementSearchEnvironment { | ||
| 708 | readonly environmentId: EnvironmentId; | ||
| 709 | readonly connection: { readonly phase: EnvironmentConnectionPhase }; | ||
| 710 | readonly serverConfig: { | ||
| 711 | readonly environment: { | ||
| 712 | readonly capabilities: { readonly threadAutoSettlement?: boolean }; | ||
| 713 | }; | ||
| 714 | } | null; | ||
| 715 | } | ||
| 716 | |||
| 717 | /** Discovery needs one capable environment; the selected page needs every connected target to support it. */ | ||
| 718 | export function getThreadAutoSettlementSearchAvailability( | ||
| 719 | environments: readonly AutoSettlementSearchEnvironment[], | ||
| 720 | scope?: Pick<ResolvedSettingsScope, "kind" | "environmentIds">, | ||
| 721 | ) { | ||
| 722 | const connected = environments.filter( | ||
| 723 | (environment) => | ||
| 724 | environment.connection.phase === "connected" && environment.serverConfig !== null, | ||
| 725 | ); | ||
| 726 | const eligibleEnvironmentIds = connected | ||
| 727 | .filter( | ||
| 728 | (environment) => | ||
| 729 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, | ||
| 730 | ) | ||
| 731 | .map((environment) => environment.environmentId); | ||
| 732 | const selected = connected.filter((environment) => | ||
| 733 | scope?.environmentIds.includes(environment.environmentId), | ||
| 734 | ); | ||
| 735 | return { | ||
| 736 | eligibleEnvironmentIds, | ||
| 737 | isTargetAvailable: | ||
| 738 | scope !== undefined && | ||
| 739 | scope.kind !== "unavailable" && | ||
| 740 | selected.length > 0 && | ||
| 741 | selected.every((environment) => eligibleEnvironmentIds.includes(environment.environmentId)), | ||
| 742 | }; | ||
| 743 | } | ||
| 744 | |||
| 745 | export function isSettingsSearchScopeAvailable( | ||
| 746 | requiredScope: SettingsSearchScope | null, | ||
| 747 | scopeKind: ResolvedSettingsScope["kind"], | ||
| 748 | ): boolean { | ||
| 749 | switch (requiredScope) { | ||
| 750 | case null: | ||
| 751 | case "connections": | ||
| 752 | return true; | ||
| 753 | case "environment": | ||
| 754 | case "checkout": | ||
| 755 | return requiredScope === scopeKind; | ||
| 756 | case "project": | ||
| 757 | return scopeKind === "project" || scopeKind === "checkout"; | ||
| 758 | case "environment-defaults": | ||
| 759 | return scopeKind === "environment" || scopeKind === "all"; | ||
| 760 | case "project-defaults": | ||
| 761 | return ( | ||
| 762 | scopeKind === "environment" || | ||
| 763 | scopeKind === "all" || | ||
| 764 | scopeKind === "project" || | ||
| 765 | scopeKind === "checkout" | ||
| 766 | ); | ||
| 767 | } | ||
| 768 | } | ||
| 769 | |||
| 770 | function settingsScopeKindFromSearch(search: SettingsScopeSearch): ResolvedSettingsScope["kind"] { | ||
| 771 | const target = validateSettingsScopeSearch({ ...search }); | ||
| 772 | if (target.checkout && !target.project) return "unavailable"; | ||
| 773 | if (target.project) return target.checkout ? "checkout" : "project"; | ||
| 774 | return target.machine ? "environment" : "all"; | ||
| 775 | } | ||
| 776 | |||
| 777 | export function isSettingsOverviewVisible(search: SettingsScopeSearch): boolean { | ||
| 778 | const kind = settingsScopeKindFromSearch(search); | ||
| 779 | return kind === "project" || kind === "checkout"; | ||
| 780 | } | ||
| 781 | |||
| 609 | /** | 782 | /** |
| 610 | * `id` and `title` props for the element a search item anchors to. Panels | 783 | * `id` and `title` props for the element a search item anchors to. Panels |
| 611 | * spread (or pick from) this instead of restating the strings, so the catalog | 784 | * spread (or pick from) this instead of restating the strings, so the catalog |
| ... | @@ -626,7 +799,7 @@ export function filterAvailableSettingsSearchItems( | ... | @@ -626,7 +799,7 @@ export function filterAvailableSettingsSearchItems( |
| 626 | return items.filter( | 799 | return items.filter( |
| 627 | (item) => | 800 | (item) => |
| 628 | (!item.cloudOnly || availability.hasCloudPublicConfig) && | 801 | (!item.cloudOnly || availability.hasCloudPublicConfig) && |
| 629 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && | 802 | (!item.environmentOnly || availability.hasEnvironment) && |
| 630 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && | 803 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && |
| 631 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && | 804 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && |
| 632 | (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && | 805 | (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && |
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+8-16| ... | @@ -1,23 +1,22 @@ | ... | @@ -1,23 +1,22 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; | 2 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 4 | 3 | ||
| 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 6 | import { isElectron } from "~/env"; | 5 | import { isElectron } from "~/env"; |
| 7 | import { desktopWslStateAtom } from "~/state/desktopWslState"; | 6 | import { desktopWslStateAtom } from "~/state/desktopWslState"; |
| 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 7 | import { useEnvironments } from "~/state/environments"; |
| 9 | import { useEnvironmentQuery } from "~/state/query"; | 8 | import { useEnvironmentQuery } from "~/state/query"; |
| 10 | import { usePrimarySessionState } from "~/environments/primary"; | 9 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | import { primaryServerConfigAtom } from "~/state/server"; | ||
| 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | 10 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; | 11 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 14 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; | 12 | import { |
| 13 | filterAvailableSettingsSearchItems, | ||
| 14 | getThreadAutoSettlementSearchAvailability, | ||
| 15 | } from "./settingsSearch"; | ||
| 15 | 16 | ||
| 16 | export function useAvailableSettingsSearchItems() { | 17 | export function useAvailableSettingsSearchItems() { |
| 17 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 18 | const { environments } = useEnvironments(); | 18 | const { environments } = useEnvironments(); |
| 19 | const primarySessionState = usePrimarySessionState(); | 19 | const primarySessionState = usePrimarySessionState(); |
| 20 | const primaryServerConfig = useAtomValue(primaryServerConfigAtom); | ||
| 21 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); | 20 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); |
| 22 | const canManageLocalBackend = | 21 | const canManageLocalBackend = |
| 23 | isElectron || | 22 | isElectron || |
| ... | @@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() { | ... | @@ -29,7 +28,7 @@ export function useAvailableSettingsSearchItems() { |
| 29 | () => | 28 | () => |
| 30 | filterAvailableSettingsSearchItems({ | 29 | filterAvailableSettingsSearchItems({ |
| 31 | hasCloudPublicConfig: hasCloudPublicConfig(), | 30 | hasCloudPublicConfig: hasCloudPublicConfig(), |
| 32 | hasPrimaryEnvironment: primaryEnvironmentId !== null, | 31 | hasEnvironment: environments.some((environment) => environment.serverConfig !== null), |
| 33 | hasProviderSettingsEnvironment: environments.some((environment) => | 32 | hasProviderSettingsEnvironment: environments.some((environment) => |
| 34 | isProviderSettingsEnvironmentAvailable({ | 33 | isProviderSettingsEnvironmentAvailable({ |
| 35 | connectionPhase: environment.connection.phase, | 34 | connectionPhase: environment.connection.phase, |
| ... | @@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() { | ... | @@ -42,15 +41,8 @@ export function useAvailableSettingsSearchItems() { |
| 42 | error: desktopWsl.error, | 41 | error: desktopWsl.error, |
| 43 | }), | 42 | }), |
| 44 | hasThreadAutoSettlement: | 43 | hasThreadAutoSettlement: |
| 45 | primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, | 44 | getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0, |
| 46 | }), | 45 | }), |
| 47 | [ | 46 | [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments], |
| 48 | canManageLocalBackend, | ||
| 49 | desktopWsl.data, | ||
| 50 | desktopWsl.error, | ||
| 51 | environments, | ||
| 52 | primaryEnvironmentId, | ||
| 53 | primaryServerConfig, | ||
| 54 | ], | ||
| 55 | ); | 47 | ); |
| 56 | } | 48 | } |
apps/web/src/components/settings/useProjectScriptSettings.ts created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | import { | ||
| 2 | isAtomCommandInterrupted, | ||
| 3 | mapAtomCommandResult, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | type AtomCommandResult, | ||
| 6 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 7 | import { | ||
| 8 | type EnvironmentId, | ||
| 9 | type ProjectId, | ||
| 10 | type ProjectScript, | ||
| 11 | type ResolvedKeybindingsConfig, | ||
| 12 | type ServerSettings, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { resolveProjectScripts } from "@t3tools/shared/projectScripts"; | ||
| 15 | import { clearProjectSettingsOverrides } from "@t3tools/shared/projectSettings"; | ||
| 16 | import * as Cause from "effect/Cause"; | ||
| 17 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 18 | import { useRef, useState } from "react"; | ||
| 19 | |||
| 20 | import { isElectron } from "../../env"; | ||
| 21 | import { | ||
| 22 | decodeProjectScriptKeybindingRule, | ||
| 23 | keybindingValueForCommand, | ||
| 24 | } from "../../lib/projectScriptKeybindings"; | ||
| 25 | import { | ||
| 26 | buildProjectScript, | ||
| 27 | commandForProjectScript, | ||
| 28 | nextProjectScriptId, | ||
| 29 | } from "../../projectScripts"; | ||
| 30 | import { useProjects } from "../../state/entities"; | ||
| 31 | import { serverEnvironment } from "../../state/server"; | ||
| 32 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 33 | import type { NewProjectScriptInput } from "../projectScriptEditor"; | ||
| 34 | import { toastManager } from "../ui/toast"; | ||
| 35 | |||
| 36 | function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) { | ||
| 37 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 38 | const error = squashAtomCommandFailure(result); | ||
| 39 | toastManager.add({ | ||
| 40 | type: "error", | ||
| 41 | title: "Failed to save project actions", | ||
| 42 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 43 | }); | ||
| 44 | } | ||
| 45 | return mapAtomCommandResult(result, () => undefined); | ||
| 46 | } | ||
| 47 | |||
| 48 | /** | ||
| 49 | * Edits the action list on every target: the environment default when there is | ||
| 50 | * no project, else that project's override entry. Shortcuts follow on desktop. | ||
| 51 | */ | ||
| 52 | export function useProjectScriptSettings( | ||
| 53 | targets: readonly { | ||
| 54 | environmentId: EnvironmentId; | ||
| 55 | settings: ServerSettings; | ||
| 56 | keybindings: ResolvedKeybindingsConfig; | ||
| 57 | project?: { id: ProjectId; scripts: readonly ProjectScript[] }; | ||
| 58 | }[], | ||
| 59 | ) { | ||
| 60 | const projects = useProjects(); | ||
| 61 | const [saving, setSaving] = useState(false); | ||
| 62 | const savingRef = useRef(false); | ||
| 63 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); | ||
| 64 | const upsertKeybinding = useAtomCommand( | ||
| 65 | serverEnvironment.upsertKeybinding, | ||
| 66 | "action shortcut update", | ||
| 67 | ); | ||
| 68 | const removeKeybinding = useAtomCommand( | ||
| 69 | serverEnvironment.removeKeybinding, | ||
| 70 | "action shortcut removal", | ||
| 71 | ); | ||
| 72 | |||
| 73 | async function persist( | ||
| 74 | transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, | ||
| 75 | scriptId?: string, | ||
| 76 | keybinding?: string | null, | ||
| 77 | ): Promise<AtomCommandResult<void, unknown>> { | ||
| 78 | if (savingRef.current || targets.length === 0) { | ||
| 79 | const message = "No available machine, or another action change is saving."; | ||
| 80 | toastManager.add({ type: "error", title: "Actions not saved", description: message }); | ||
| 81 | return AsyncResult.failure(Cause.fail(new Error(message))); | ||
| 82 | } | ||
| 83 | savingRef.current = true; | ||
| 84 | setSaving(true); | ||
| 85 | try { | ||
| 86 | for (const { environmentId, settings, keybindings, project } of targets) { | ||
| 87 | const current = project | ||
| 88 | ? resolveProjectScripts(settings, project) | ||
| 89 | : settings.defaultProjectScripts; | ||
| 90 | const nextScripts = transform(current); | ||
| 91 | const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; | ||
| 92 | const result = await updateSettings({ | ||
| 93 | environmentId, | ||
| 94 | input: { | ||
| 95 | patch: project | ||
| 96 | ? { | ||
| 97 | projectSettingsOverrides: { | ||
| 98 | [project.id]: | ||
| 99 | nextScripts === null | ||
| 100 | ? clearProjectSettingsOverrides(settings, project.id, [ | ||
| 101 | "defaultProjectScripts", | ||
| 102 | ]) | ||
| 103 | : { | ||
| 104 | ...settings.projectSettingsOverrides[project.id], | ||
| 105 | defaultProjectScripts: nextScripts, | ||
| 106 | }, | ||
| 107 | }, | ||
| 108 | } | ||
| 109 | : { defaultProjectScripts: nextScripts ?? [] }, | ||
| 110 | }, | ||
| 111 | }); | ||
| 112 | if (result._tag === "Failure") return reportScriptFailure(result); | ||
| 113 | if (!isElectron) continue; | ||
| 114 | const changedIds = scriptId | ||
| 115 | ? [scriptId] | ||
| 116 | : current | ||
| 117 | .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) | ||
| 118 | .map((script) => script.id); | ||
| 119 | for (const id of changedIds) { | ||
| 120 | const command = commandForProjectScript(id); | ||
| 121 | const previousValue = keybindingValueForCommand(keybindings, command); | ||
| 122 | const previous = previousValue | ||
| 123 | ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) | ||
| 124 | : null; | ||
| 125 | const next = decodeProjectScriptKeybindingRule({ keybinding, command }); | ||
| 126 | const retainedElsewhere = | ||
| 127 | !nextScripts?.some((script) => script.id === id) && | ||
| 128 | ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || | ||
| 129 | Object.entries(settings.projectSettingsOverrides).some( | ||
| 130 | ([projectId, entry]) => | ||
| 131 | projectId !== project?.id && | ||
| 132 | entry.defaultProjectScripts?.some((script) => script.id === id), | ||
| 133 | ) || | ||
| 134 | projects.some( | ||
| 135 | (other) => | ||
| 136 | other.environmentId === environmentId && | ||
| 137 | other.id !== project?.id && | ||
| 138 | (project ? resolveProjectScripts(settings, other) : other.scripts).some( | ||
| 139 | (script) => script.id === id, | ||
| 140 | ), | ||
| 141 | )); | ||
| 142 | const bindingResult = next | ||
| 143 | ? await upsertKeybinding({ | ||
| 144 | environmentId, | ||
| 145 | input: | ||
| 146 | previous && previous.key !== next.key ? { ...next, replace: previous } : next, | ||
| 147 | }) | ||
| 148 | : previous && !retainedElsewhere | ||
| 149 | ? await removeKeybinding({ environmentId, input: previous }) | ||
| 150 | : null; | ||
| 151 | if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | return AsyncResult.success(undefined); | ||
| 155 | } finally { | ||
| 156 | savingRef.current = false; | ||
| 157 | setSaving(false); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | function submit(scriptId: string | null, input: NewProjectScriptInput) { | ||
| 162 | const existingIds = [ | ||
| 163 | ...projects.flatMap((project) => project.scripts.map((script) => script.id)), | ||
| 164 | ...targets.flatMap(({ settings, project }) => | ||
| 165 | [ | ||
| 166 | ...settings.defaultProjectScripts, | ||
| 167 | ...Object.values(settings.projectSettingsOverrides).flatMap( | ||
| 168 | (entry) => entry.defaultProjectScripts ?? [], | ||
| 169 | ), | ||
| 170 | ...(project?.scripts ?? []), | ||
| 171 | ].map((script) => script.id), | ||
| 172 | ), | ||
| 173 | ]; | ||
| 174 | const id = scriptId ?? nextProjectScriptId(input.name, existingIds); | ||
| 175 | const next = buildProjectScript(id, input); | ||
| 176 | return persist( | ||
| 177 | (current) => { | ||
| 178 | const updated = current.map((script) => | ||
| 179 | script.id === id | ||
| 180 | ? next | ||
| 181 | : input.runOnWorktreeCreate | ||
| 182 | ? { ...script, runOnWorktreeCreate: false } | ||
| 183 | : script, | ||
| 184 | ); | ||
| 185 | return scriptId === null ? [...updated, next] : updated; | ||
| 186 | }, | ||
| 187 | id, | ||
| 188 | input.keybinding, | ||
| 189 | ); | ||
| 190 | } | ||
| 191 | |||
| 192 | return { saving, persist, submit }; | ||
| 193 | } | ||
apps/web/src/components/settings/useScopedModelAvailability.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | |||
| 4 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | ||
| 5 | import { | ||
| 6 | applyProviderInstanceSettings, | ||
| 7 | deriveProviderInstanceEntries, | ||
| 8 | type ProviderInstanceEntry, | ||
| 9 | } from "../../providerInstances"; | ||
| 10 | import { useEnvironments } from "../../state/environments"; | ||
| 11 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 12 | |||
| 13 | /** | ||
| 14 | * A model choice fans out to every selected target, so it must exist on all | ||
| 15 | * of them. Returns the reason a (instance, model) pair cannot be applied, or | ||
| 16 | * null when every target can honor it. The representative's entries decide | ||
| 17 | * which driver the instance id names. | ||
| 18 | */ | ||
| 19 | export function useScopedModelDisabledReason( | ||
| 20 | settings: UnifiedSettings, | ||
| 21 | entries: readonly ProviderInstanceEntry[], | ||
| 22 | ) { | ||
| 23 | const { targets } = useSettingsScope(); | ||
| 24 | const { environments } = useEnvironments(); | ||
| 25 | return useCallback( | ||
| 26 | (instanceId: ProviderInstanceId, model: string): string | null => { | ||
| 27 | const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); | ||
| 28 | for (const candidate of targets) { | ||
| 29 | const environment = environments.find( | ||
| 30 | (entry) => entry.environmentId === candidate.environmentId, | ||
| 31 | ); | ||
| 32 | const config = environment?.serverConfig; | ||
| 33 | if (!config) continue; | ||
| 34 | const entry = applyProviderInstanceSettings( | ||
| 35 | deriveProviderInstanceEntries(config.providers), | ||
| 36 | candidate.settings, | ||
| 37 | ).find((option) => option.instanceId === instanceId); | ||
| 38 | const options = getCustomModelOptionsByInstance( | ||
| 39 | { ...settings, ...candidate.settings }, | ||
| 40 | config.providers, | ||
| 41 | ).get(instanceId); | ||
| 42 | if ( | ||
| 43 | !entry?.enabled || | ||
| 44 | !entry.isAvailable || | ||
| 45 | entry.driverKind !== sourceEntry?.driverKind || | ||
| 46 | !options?.some((option) => option.slug === model && !option.isUnavailable) | ||
| 47 | ) { | ||
| 48 | return `This model is unavailable on ${environment?.label ?? "a selected environment"}. Select that environment to choose its model separately.`; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | return null; | ||
| 52 | }, | ||
| 53 | [entries, environments, settings, targets], | ||
| 54 | ); | ||
| 55 | } | ||
apps/web/src/components/settings/useScopedSettings.ts created+121| ... | @@ -0,0 +1,121 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | type ProjectScopedServerSettingKey, | ||
| 4 | type ServerSettings, | ||
| 5 | type UnifiedSettings, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { useCallback, useMemo } from "react"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | mergeEnvironmentSettings, | ||
| 11 | persistClientSettingsPatch, | ||
| 12 | useClientSettings, | ||
| 13 | } from "../../hooks/useSettings"; | ||
| 14 | import { serverEnvironment } from "../../state/server"; | ||
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 16 | import { toastManager } from "../ui/toast"; | ||
| 17 | import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; | ||
| 18 | import { | ||
| 19 | persistScopedSettingsPatch, | ||
| 20 | planProjectOverridesClear, | ||
| 21 | planScopedSettingsClear, | ||
| 22 | planScopedSettingsPatch, | ||
| 23 | scopedSettingsAreMixed, | ||
| 24 | scopedSettingsSource, | ||
| 25 | type ProjectOverrideEntry, | ||
| 26 | type ScopedSettingsPatch, | ||
| 27 | } from "./scopedSettings"; | ||
| 28 | |||
| 29 | /** Effective settings for the representative target: project overrides applied on top of its environment. */ | ||
| 30 | export function useScopedSettings<T = UnifiedSettings>( | ||
| 31 | selector?: (settings: UnifiedSettings) => T, | ||
| 32 | ): T { | ||
| 33 | const { target } = useSettingsScope(); | ||
| 34 | const clientSettings = useClientSettings(); | ||
| 35 | const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS; | ||
| 36 | const settings = useMemo( | ||
| 37 | () => mergeEnvironmentSettings(serverSettings, clientSettings), | ||
| 38 | [clientSettings, serverSettings], | ||
| 39 | ); | ||
| 40 | return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean { | ||
| 44 | const { targets } = useSettingsScope(); | ||
| 45 | return scopedSettingsAreMixed(targets, keys); | ||
| 46 | } | ||
| 47 | |||
| 48 | /** Where the keys' effective values come from across the selected targets. */ | ||
| 49 | export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) { | ||
| 50 | const { targets } = useSettingsScope(); | ||
| 51 | return scopedSettingsSource(targets, keys); | ||
| 52 | } | ||
| 53 | |||
| 54 | function useRunScopedPlan() { | ||
| 55 | const persistServer = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); | ||
| 56 | return useCallback( | ||
| 57 | (plan: ReturnType<typeof planScopedSettingsPatch>) => { | ||
| 58 | if (plan.unavailableReason) { | ||
| 59 | toastManager.add({ | ||
| 60 | type: "warning", | ||
| 61 | title: "Setting not saved", | ||
| 62 | description: plan.unavailableReason, | ||
| 63 | }); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then( | ||
| 67 | ({ failedEnvironments, savedEnvironmentCount }) => { | ||
| 68 | if (failedEnvironments.length === 0) return; | ||
| 69 | toastManager.add({ | ||
| 70 | type: "error", | ||
| 71 | title: | ||
| 72 | savedEnvironmentCount > 0 | ||
| 73 | ? "Setting saved on some environments" | ||
| 74 | : "Setting not saved", | ||
| 75 | description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`, | ||
| 76 | }); | ||
| 77 | }, | ||
| 78 | ); | ||
| 79 | }, | ||
| 80 | [persistServer], | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | export function useUpdateScopedSettings() { | ||
| 85 | const { scope, environments } = useSettingsScope(); | ||
| 86 | const run = useRunScopedPlan(); | ||
| 87 | return useCallback( | ||
| 88 | (patch: ScopedSettingsPatch) => run(planScopedSettingsPatch(scope, environments, patch)), | ||
| 89 | [environments, run, scope], | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | |||
| 93 | /** | ||
| 94 | * Drop the project overrides for `keys` so the selected checkouts inherit | ||
| 95 | * again. Rows also render outside the settings layout (provider cards, | ||
| 96 | * dialogs), where there is no scope and nothing to clear. | ||
| 97 | */ | ||
| 98 | export function useClearScopedSettings() { | ||
| 99 | const context = useOptionalSettingsScope(); | ||
| 100 | const run = useRunScopedPlan(); | ||
| 101 | return useCallback( | ||
| 102 | (keys: readonly ProjectScopedServerSettingKey[]) => { | ||
| 103 | if (context === null) return; | ||
| 104 | run(planScopedSettingsClear(context.scope, context.environments, keys)); | ||
| 105 | }, | ||
| 106 | [context, run], | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | /** Clear `keys` on specific project entries, from an environment scope's chain popover. */ | ||
| 111 | export function useClearProjectOverrides() { | ||
| 112 | const context = useOptionalSettingsScope(); | ||
| 113 | const run = useRunScopedPlan(); | ||
| 114 | return useCallback( | ||
| 115 | (entries: readonly ProjectOverrideEntry[], keys: readonly ProjectScopedServerSettingKey[]) => { | ||
| 116 | if (context === null) return; | ||
| 117 | run(planProjectOverridesClear(context.environments, entries, keys)); | ||
| 118 | }, | ||
| 119 | [context, run], | ||
| 120 | ); | ||
| 121 | } | ||
apps/web/src/components/ui/button.tsx+2| ... | @@ -33,6 +33,8 @@ const buttonVariants = cva( | ... | @@ -33,6 +33,8 @@ const buttonVariants = cva( |
| 33 | micro: | 33 | micro: |
| 34 | "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3", | 34 | "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3", |
| 35 | sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", | 35 | sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", |
| 36 | "sm-multiline": | ||
| 37 | "min-h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] py-[calc(--spacing(1)-1px)] whitespace-normal sm:min-h-7", | ||
| 36 | xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", | 38 | xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", |
| 37 | xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", | 39 | xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", |
| 38 | }, | 40 | }, |
apps/web/src/components/ui/switch.tsx+12-2| ... | @@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; | ... | @@ -4,15 +4,21 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; |
| 4 | 4 | ||
| 5 | import { cn } from "~/lib/utils"; | 5 | import { cn } from "~/lib/utils"; |
| 6 | 6 | ||
| 7 | /** | ||
| 8 | * `mixed` renders the thumb centred on a muted track for a selection whose | ||
| 9 | * targets disagree (the macOS mixed-state convention). It is presentational: | ||
| 10 | * the caller still decides what a click sets, usually on for everyone. | ||
| 11 | */ | ||
| 7 | function Switch({ | 12 | function Switch({ |
| 8 | className, | 13 | className, |
| 9 | size = "default", | 14 | size = "default", |
| 15 | mixed = false, | ||
| 10 | ...props | 16 | ...props |
| 11 | }: SwitchPrimitive.Root.Props & { size?: "default" | "sm" }) { | 17 | }: SwitchPrimitive.Root.Props & { size?: "default" | "sm"; mixed?: boolean }) { |
| 12 | return ( | 18 | return ( |
| 13 | <SwitchPrimitive.Root | 19 | <SwitchPrimitive.Root |
| 14 | className={cn( | 20 | className={cn( |
| 15 | "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center rounded-full p-[2px] outline-none transition-[background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-64", | 21 | "inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 cursor-pointer items-center rounded-full p-[2px] outline-none transition-[background-color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:cursor-not-allowed data-disabled:opacity-64 data-[mixed]:bg-input", |
| 16 | size === "sm" | 22 | size === "sm" |
| 17 | ? "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]" | 23 | ? "[--thumb-size:--spacing(4)] sm:[--thumb-size:--spacing(3.5)]" |
| 18 | : "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]", | 24 | : "[--thumb-size:--spacing(5)] sm:[--thumb-size:--spacing(4)]", |
| ... | @@ -20,11 +26,15 @@ function Switch({ | ... | @@ -20,11 +26,15 @@ function Switch({ |
| 20 | )} | 26 | )} |
| 21 | data-size={size} | 27 | data-size={size} |
| 22 | data-slot="switch" | 28 | data-slot="switch" |
| 29 | data-mixed={mixed ? "" : undefined} | ||
| 30 | aria-checked={mixed ? "mixed" : undefined} | ||
| 23 | {...props} | 31 | {...props} |
| 24 | > | 32 | > |
| 25 | <SwitchPrimitive.Thumb | 33 | <SwitchPrimitive.Thumb |
| 26 | className={cn( | 34 | className={cn( |
| 27 | "pointer-events-none block size-[calc(var(--thumb-size)-2px)] shrink-0 origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-right data-checked:translate-x-[calc(var(--thumb-size)-4px)]", | 35 | "pointer-events-none block size-[calc(var(--thumb-size)-2px)] shrink-0 origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-right data-checked:translate-x-[calc(var(--thumb-size)-4px)]", |
| 36 | mixed && | ||
| 37 | "translate-x-[calc((var(--thumb-size)-4px)/2)] opacity-70 data-checked:translate-x-[calc((var(--thumb-size)-4px)/2)]", | ||
| 28 | )} | 38 | )} |
| 29 | data-slot="switch-thumb" | 39 | data-slot="switch-thumb" |
| 30 | /> | 40 | /> |
apps/web/src/hooks/useHandleNewThread.test.ts+104-21| ... | @@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; | ... | @@ -3,6 +3,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 3 | const testState = vi.hoisted(() => { | 3 | const testState = vi.hoisted(() => { |
| 4 | let completeProjectFileRead: (value: null) => void = () => undefined; | 4 | let completeProjectFileRead: (value: null) => void = () => undefined; |
| 5 | let projectFileRead = Promise.resolve<null>(null); | 5 | let projectFileRead = Promise.resolve<null>(null); |
| 6 | let targetSettings = { | ||
| 7 | defaultThreadEnvMode: "local" as "local" | "worktree", | ||
| 8 | newWorktreesStartFromOrigin: false, | ||
| 9 | defaultModelSelection: null, | ||
| 10 | }; | ||
| 6 | let storedDraft: { | 11 | let storedDraft: { |
| 7 | readonly draftId: string; | 12 | readonly draftId: string; |
| 8 | readonly environmentId: string; | 13 | readonly environmentId: string; |
| ... | @@ -35,10 +40,25 @@ const testState = vi.hoisted(() => { | ... | @@ -35,10 +40,25 @@ const testState = vi.hoisted(() => { |
| 35 | get projectFileRead() { | 40 | get projectFileRead() { |
| 36 | return projectFileRead; | 41 | return projectFileRead; |
| 37 | }, | 42 | }, |
| 38 | reset(nextStoredDraft: typeof storedDraft) { | 43 | get targetSettings() { |
| 44 | return targetSettings; | ||
| 45 | }, | ||
| 46 | reset( | ||
| 47 | nextStoredDraft: typeof storedDraft, | ||
| 48 | workspaceDefaults = { | ||
| 49 | envMode: "local" as "local" | "worktree", | ||
| 50 | startFromOrigin: false, | ||
| 51 | }, | ||
| 52 | ) { | ||
| 39 | storedDraft = nextStoredDraft; | 53 | storedDraft = nextStoredDraft; |
| 54 | targetSettings = { | ||
| 55 | defaultThreadEnvMode: workspaceDefaults.envMode, | ||
| 56 | newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, | ||
| 57 | defaultModelSelection: null, | ||
| 58 | }; | ||
| 40 | router.state.location.href = "/"; | 59 | router.state.location.href = "/"; |
| 41 | router.navigate.mockClear(); | 60 | router.navigate.mockClear(); |
| 61 | draftStore.setDraftThreadContext.mockClear(); | ||
| 42 | draftStore.setLogicalProjectDraftThreadId.mockClear(); | 62 | draftStore.setLogicalProjectDraftThreadId.mockClear(); |
| 43 | projectFileRead = new Promise<null>((resolve) => { | 63 | projectFileRead = new Promise<null>((resolve) => { |
| 44 | completeProjectFileRead = resolve; | 64 | completeProjectFileRead = resolve; |
| ... | @@ -51,18 +71,18 @@ const testState = vi.hoisted(() => { | ... | @@ -51,18 +71,18 @@ const testState = vi.hoisted(() => { |
| 51 | vi.mock("@effect/atom-react", () => ({ | 71 | vi.mock("@effect/atom-react", () => ({ |
| 52 | useAtomValue: (atom: unknown) => | 72 | useAtomValue: (atom: unknown) => |
| 53 | atom === "primary-settings" | 73 | atom === "primary-settings" |
| 54 | ? { newWorktreesStartFromOrigin: false } | 74 | ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } |
| 55 | : new Map([ | 75 | : new Map([ |
| 56 | [ | 76 | [ |
| 57 | "environment-ssh", | 77 | "environment-primary", |
| 58 | { | 78 | { |
| 59 | settings: { | 79 | settings: { |
| 60 | defaultThreadEnvMode: "local", | 80 | ...testState.targetSettings, |
| 61 | newWorktreesStartFromOrigin: false, | 81 | newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin, |
| 62 | defaultModelSelection: null, | ||
| 63 | }, | 82 | }, |
| 64 | }, | 83 | }, |
| 65 | ], | 84 | ], |
| 85 | ["environment-ssh", { settings: testState.targetSettings }], | ||
| 66 | ]), | 86 | ]), |
| 67 | })); | 87 | })); |
| 68 | vi.mock("@t3tools/client-runtime/environment", () => ({ | 88 | vi.mock("@t3tools/client-runtime/environment", () => ({ |
| ... | @@ -74,6 +94,15 @@ vi.mock("@t3tools/contracts", () => ({ | ... | @@ -74,6 +94,15 @@ vi.mock("@t3tools/contracts", () => ({ |
| 74 | DEFAULT_RUNTIME_MODE: "default", | 94 | DEFAULT_RUNTIME_MODE: "default", |
| 75 | DEFAULT_SERVER_SETTINGS: {}, | 95 | DEFAULT_SERVER_SETTINGS: {}, |
| 76 | })); | 96 | })); |
| 97 | vi.mock("@t3tools/shared/projectSettings", () => ({ | ||
| 98 | // Environment settings pass through; the tests set project fields on the | ||
| 99 | // project record, which the hook still honors until the server folds them. | ||
| 100 | resolveProjectSettings: (settings: Record<string, unknown>) => ({ | ||
| 101 | settings, | ||
| 102 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, | ||
| 103 | overrides: {}, | ||
| 104 | }), | ||
| 105 | })); | ||
| 77 | vi.mock("@t3tools/shared/threadEnvMode", () => ({ | 106 | vi.mock("@t3tools/shared/threadEnvMode", () => ({ |
| 78 | resolveDefaultThreadEnvMode: (input: { | 107 | resolveDefaultThreadEnvMode: (input: { |
| 79 | readonly projectFile: "local" | "worktree" | null; | 108 | readonly projectFile: "local" | "worktree" | null; |
| ... | @@ -99,9 +128,9 @@ vi.mock("../composerDraftStore", () => { | ... | @@ -99,9 +128,9 @@ vi.mock("../composerDraftStore", () => { |
| 99 | useComposerDraftStore, | 128 | useComposerDraftStore, |
| 100 | }; | 129 | }; |
| 101 | }); | 130 | }); |
| 102 | vi.mock("../lib/chatThreadActions", () => ({ | 131 | vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ |
| 132 | ...(await importOriginal<typeof import("../lib/chatThreadActions")>()), | ||
| 103 | hasExplicitComposerModelSelection: () => false, | 133 | hasExplicitComposerModelSelection: () => false, |
| 104 | resolveNewDraftStartFromOrigin: () => false, | ||
| 105 | resolveNewThreadModelSelectionOverride: () => null, | 134 | resolveNewThreadModelSelectionOverride: () => null, |
| 106 | })); | 135 | })); |
| 107 | vi.mock("../lib/t3ProjectFileDefaults", () => ({ | 136 | vi.mock("../lib/t3ProjectFileDefaults", () => ({ |
| ... | @@ -143,19 +172,19 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); | ... | @@ -143,19 +172,19 @@ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); |
| 143 | 172 | ||
| 144 | import { useNewThreadHandler } from "./useHandleNewThread"; | 173 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 145 | 174 | ||
| 146 | describe("useNewThreadHandler", () => { | 175 | describe.each([ |
| 147 | it.each([ | 176 | ["new", null], |
| 148 | ["new", null], | 177 | [ |
| 149 | [ | 178 | "reusable", |
| 150 | "reusable", | 179 | { |
| 151 | { | 180 | draftId: "draft-existing", |
| 152 | draftId: "draft-existing", | 181 | environmentId: "environment-ssh", |
| 153 | environmentId: "environment-ssh", | 182 | promotedTo: null, |
| 154 | promotedTo: null, | 183 | threadId: "thread-existing", |
| 155 | threadId: "thread-existing", | 184 | }, |
| 156 | }, | 185 | ], |
| 157 | ], | 186 | ])("useNewThreadHandler with a %s draft", (_, draft) => { |
| 158 | ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => { | 187 | it("abandons a delayed draft open when the user navigates elsewhere", async () => { |
| 159 | testState.reset(draft); | 188 | testState.reset(draft); |
| 160 | const openThread = useNewThreadHandler(); | 189 | const openThread = useNewThreadHandler(); |
| 161 | const pendingOpen = openThread( | 190 | const pendingOpen = openThread( |
| ... | @@ -171,4 +200,58 @@ describe("useNewThreadHandler", () => { | ... | @@ -171,4 +200,58 @@ describe("useNewThreadHandler", () => { |
| 171 | expect(testState.router.navigate).not.toHaveBeenCalled(); | 200 | expect(testState.router.navigate).not.toHaveBeenCalled(); |
| 172 | expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); | 201 | expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); |
| 173 | }); | 202 | }); |
| 203 | |||
| 204 | it.each([true, false])( | ||
| 205 | "uses the target environment's start-from-origin default of %s", | ||
| 206 | async (startFromOrigin) => { | ||
| 207 | testState.reset(draft, { envMode: "worktree", startFromOrigin }); | ||
| 208 | const openThread = useNewThreadHandler(); | ||
| 209 | const projectRef = { | ||
| 210 | environmentId: "environment-ssh", | ||
| 211 | projectId: "project-remote", | ||
| 212 | } as never; | ||
| 213 | const pendingOpen = openThread(projectRef); | ||
| 214 | |||
| 215 | testState.completeProjectFileRead(null); | ||
| 216 | const opened = await pendingOpen; | ||
| 217 | |||
| 218 | expect(opened).toEqual({ | ||
| 219 | draftId: draft?.draftId ?? "draft-delayed", | ||
| 220 | threadId: draft?.threadId ?? "thread-delayed", | ||
| 221 | }); | ||
| 222 | expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( | ||
| 223 | "remote-project", | ||
| 224 | projectRef, | ||
| 225 | opened!.draftId, | ||
| 226 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), | ||
| 227 | ); | ||
| 228 | if (draft) { | ||
| 229 | expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith( | ||
| 230 | draft.draftId, | ||
| 231 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | }, | ||
| 235 | ); | ||
| 236 | |||
| 237 | it.each([true, false])( | ||
| 238 | "preserves an explicit start-from-origin choice of %s", | ||
| 239 | async (startFromOrigin) => { | ||
| 240 | testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin }); | ||
| 241 | const openThread = useNewThreadHandler(); | ||
| 242 | const projectRef = { | ||
| 243 | environmentId: "environment-ssh", | ||
| 244 | projectId: "project-remote", | ||
| 245 | } as never; | ||
| 246 | |||
| 247 | const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin }); | ||
| 248 | |||
| 249 | expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( | ||
| 250 | "remote-project", | ||
| 251 | projectRef, | ||
| 252 | opened!.draftId, | ||
| 253 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), | ||
| 254 | ); | ||
| 255 | }, | ||
| 256 | ); | ||
| 174 | }); | 257 | }); |
apps/web/src/hooks/useHandleNewThread.ts+21-16| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | getProjectOrderKey, | 27 | getProjectOrderKey, |
| 28 | selectProjectGroupingSettings, | 28 | selectProjectGroupingSettings, |
| 29 | } from "../logicalProject"; | 29 | } from "../logicalProject"; |
| 30 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 30 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | 31 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 31 | import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; | 32 | import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; |
| 32 | import { | 33 | import { |
| ... | @@ -35,7 +36,7 @@ import { | ... | @@ -35,7 +36,7 @@ import { |
| 35 | resolveNewThreadModelSelectionOverride, | 36 | resolveNewThreadModelSelectionOverride, |
| 36 | } from "../lib/chatThreadActions"; | 37 | } from "../lib/chatThreadActions"; |
| 37 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; | 38 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; |
| 38 | import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; | 39 | import { environmentServerConfigsAtom } from "../state/server"; |
| 39 | import { resolveThreadRouteTarget } from "../threadRoutes"; | 40 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 40 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 41 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 41 | import { useClientSettings } from "./useSettings"; | 42 | import { useClientSettings } from "./useSettings"; |
| ... | @@ -61,7 +62,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef | ... | @@ -61,7 +62,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef |
| 61 | 62 | ||
| 62 | export function useNewThreadHandler() { | 63 | export function useNewThreadHandler() { |
| 63 | const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); | 64 | const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); |
| 64 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); | ||
| 65 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 65 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 66 | const router = useRouter(); | 66 | const router = useRouter(); |
| 67 | const getCurrentRouteTarget = useCallback(() => { | 67 | const getCurrentRouteTarget = useCallback(() => { |
| ... | @@ -139,10 +139,21 @@ export function useNewThreadHandler() { | ... | @@ -139,10 +139,21 @@ export function useNewThreadHandler() { |
| 139 | candidate.id === projectRef.projectId && | 139 | candidate.id === projectRef.projectId && |
| 140 | candidate.environmentId === projectRef.environmentId, | 140 | candidate.environmentId === projectRef.environmentId, |
| 141 | ); | 141 | ); |
| 142 | // The resolver applies project overrides and, until the server has | ||
| 143 | // folded them, the aggregate's own legacy fields. | ||
| 144 | const projectSettings = resolveProjectSettings( | ||
| 145 | targetServerSettings, | ||
| 146 | project?.id ?? null, | ||
| 147 | project, | ||
| 148 | ); | ||
| 149 | const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; | ||
| 150 | const projectThreadEnvMode = | ||
| 151 | projectSettings.sources.defaultThreadEnvMode === "project" | ||
| 152 | ? projectSettings.settings.defaultThreadEnvMode | ||
| 153 | : undefined; | ||
| 142 | const resolveModelSelectionOverride = (destinationDraftId: DraftId) => | 154 | const resolveModelSelectionOverride = (destinationDraftId: DraftId) => |
| 143 | resolveNewThreadModelSelectionOverride({ | 155 | resolveNewThreadModelSelectionOverride({ |
| 144 | projectDefaultSelection: | 156 | projectDefaultSelection: projectDefaultModelSelection ?? null, |
| 145 | project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, | ||
| 146 | carrySelection: carryModelSelection, | 157 | carrySelection: carryModelSelection, |
| 147 | carrySourceDraftId: | 158 | carrySourceDraftId: |
| 148 | currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, | 159 | currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, |
| ... | @@ -152,16 +163,16 @@ export function useNewThreadHandler() { | ... | @@ -152,16 +163,16 @@ export function useNewThreadHandler() { |
| 152 | // skipped entirely when a higher-priority source decides, and its | 163 | // skipped entirely when a higher-priority source decides, and its |
| 153 | // query atom caches per project after the first call. | 164 | // query atom caches per project after the first call. |
| 154 | const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => { | 165 | const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => { |
| 155 | const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null; | 166 | const consultProjectFile = project !== undefined && projectThreadEnvMode == null; |
| 156 | return resolveDefaultThreadEnvMode({ | 167 | return resolveDefaultThreadEnvMode({ |
| 157 | projectSetting: project?.defaultThreadEnvMode, | 168 | projectSetting: projectThreadEnvMode, |
| 158 | projectFile: consultProjectFile | 169 | projectFile: consultProjectFile |
| 159 | ? await readT3ProjectFileDefaultThreadEnvMode( | 170 | ? await readT3ProjectFileDefaultThreadEnvMode( |
| 160 | project.environmentId, | 171 | project.environmentId, |
| 161 | project.workspaceRoot, | 172 | project.workspaceRoot, |
| 162 | ) | 173 | ) |
| 163 | : null, | 174 | : null, |
| 164 | globalDefault: targetServerSettings.defaultThreadEnvMode, | 175 | globalDefault: projectSettings.settings.defaultThreadEnvMode, |
| 165 | }); | 176 | }); |
| 166 | }; | 177 | }; |
| 167 | const logicalProjectKey = project | 178 | const logicalProjectKey = project |
| ... | @@ -258,7 +269,7 @@ export function useNewThreadHandler() { | ... | @@ -258,7 +269,7 @@ export function useNewThreadHandler() { |
| 258 | envMode: defaultEnvMode, | 269 | envMode: defaultEnvMode, |
| 259 | startFromOrigin: resolveNewDraftStartFromOrigin({ | 270 | startFromOrigin: resolveNewDraftStartFromOrigin({ |
| 260 | envMode: defaultEnvMode, | 271 | envMode: defaultEnvMode, |
| 261 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | 272 | newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, |
| 262 | }), | 273 | }), |
| 263 | }; | 274 | }; |
| 264 | } | 275 | } |
| ... | @@ -413,7 +424,7 @@ export function useNewThreadHandler() { | ... | @@ -413,7 +424,7 @@ export function useNewThreadHandler() { |
| 413 | options?.startFromOrigin ?? | 424 | options?.startFromOrigin ?? |
| 414 | resolveNewDraftStartFromOrigin({ | 425 | resolveNewDraftStartFromOrigin({ |
| 415 | envMode: initialEnvMode, | 426 | envMode: initialEnvMode, |
| 416 | newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, | 427 | newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, |
| 417 | }), | 428 | }), |
| 418 | runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, | 429 | runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, |
| 419 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), | 430 | ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), |
| ... | @@ -433,13 +444,7 @@ export function useNewThreadHandler() { | ... | @@ -433,13 +444,7 @@ export function useNewThreadHandler() { |
| 433 | return { draftId, threadId }; | 444 | return { draftId, threadId }; |
| 434 | })(); | 445 | })(); |
| 435 | }, | 446 | }, |
| 436 | [ | 447 | [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router], |
| 437 | environmentServerConfigs, | ||
| 438 | getCurrentRouteTarget, | ||
| 439 | primaryServerSettings.newWorktreesStartFromOrigin, | ||
| 440 | projectGroupingSettings, | ||
| 441 | router, | ||
| 442 | ], | ||
| 443 | ); | 448 | ); |
| 444 | } | 449 | } |
| 445 | 450 |
apps/web/src/hooks/useSettings.ts-69| ... | @@ -27,8 +27,6 @@ import { | ... | @@ -27,8 +27,6 @@ import { |
| 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 28 | import { | 28 | import { |
| 29 | filterSharedServerPatch, | 29 | filterSharedServerPatch, |
| 30 | findSharedSettingsMismatches, | ||
| 31 | pickSharedServerSettings, | ||
| 32 | splitSharedServerPatch, | 30 | splitSharedServerPatch, |
| 33 | supportsSharedSettingsSync, | 31 | supportsSharedSettingsSync, |
| 34 | } from "@t3tools/client-runtime/state/shared-settings"; | 32 | } from "@t3tools/client-runtime/state/shared-settings"; |
| ... | @@ -493,73 +491,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -493,73 +491,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 493 | return updateSettings; | 491 | return updateSettings; |
| 494 | } | 492 | } |
| 495 | 493 | ||
| 496 | /** | ||
| 497 | * Shared-settings sync targets whose values differ from the primary's, | ||
| 498 | * plus an action that writes the primary's values to all of them. Drift | ||
| 499 | * happens when an environment was offline during an edit or was changed by | ||
| 500 | * an older client. | ||
| 501 | */ | ||
| 502 | export function useSharedSettingsSync() { | ||
| 503 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 504 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | ||
| 505 | const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities; | ||
| 506 | // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls | ||
| 507 | // back to defaults while the primary is disconnected, and "apply to all" | ||
| 508 | // must never push defaults over real values. Same for a primary too old to | ||
| 509 | // hold the shared keys: its decoded defaults are not a source of truth. | ||
| 510 | const primarySettings = | ||
| 511 | primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment) | ||
| 512 | ? (primaryEnvironment.serverConfig?.settings ?? null) | ||
| 513 | : null; | ||
| 514 | const { environments } = useEnvironments(); | ||
| 515 | const persistServerSettings = useAtomCommand( | ||
| 516 | serverEnvironment.updateSettings, | ||
| 517 | "server settings update", | ||
| 518 | ); | ||
| 519 | |||
| 520 | const mismatches = useMemo( | ||
| 521 | () => | ||
| 522 | findSharedSettingsMismatches({ | ||
| 523 | primaryEnvironmentId, | ||
| 524 | primarySettings, | ||
| 525 | primaryCapabilities, | ||
| 526 | environments: environments.map((environment) => ({ | ||
| 527 | environmentId: environment.environmentId, | ||
| 528 | label: environment.label, | ||
| 529 | syncEligible: supportsSharedSettingsSync(environment), | ||
| 530 | settings: environment.serverConfig?.settings ?? null, | ||
| 531 | capabilities: environment.serverConfig?.environment.capabilities, | ||
| 532 | })), | ||
| 533 | }), | ||
| 534 | [environments, primaryEnvironmentId, primarySettings, primaryCapabilities], | ||
| 535 | ); | ||
| 536 | |||
| 537 | const applyToAll = useCallback(() => { | ||
| 538 | if (primarySettings === null) { | ||
| 539 | return; | ||
| 540 | } | ||
| 541 | const patch = pickSharedServerSettings(primarySettings, primaryCapabilities); | ||
| 542 | for (const mismatch of mismatches) { | ||
| 543 | const target = environments.find( | ||
| 544 | (candidate) => candidate.environmentId === mismatch.environmentId, | ||
| 545 | ); | ||
| 546 | void persistServerSettings({ | ||
| 547 | environmentId: mismatch.environmentId, | ||
| 548 | input: { | ||
| 549 | patch: filterSharedServerPatch( | ||
| 550 | patch, | ||
| 551 | target?.serverConfig?.environment.capabilities, | ||
| 552 | target?.serverConfig?.settings, | ||
| 553 | primarySettings, | ||
| 554 | ), | ||
| 555 | }, | ||
| 556 | }); | ||
| 557 | } | ||
| 558 | }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]); | ||
| 559 | |||
| 560 | return { mismatches, applyToAll }; | ||
| 561 | } | ||
| 562 | |||
| 563 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { | 494 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { |
| 564 | return useUpdateSettingsTarget(environmentId); | 495 | return useUpdateSettingsTarget(environmentId); |
| 565 | } | 496 | } |
apps/web/src/lib/resourceTelemetryState.ts+20-5| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | ResourceTelemetryHistoryInput, | ||
| 4 | ResourceTelemetrySnapshot, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 3 | import { useCallback } from "react"; | 7 | import { useCallback } from "react"; |
| 4 | 8 | ||
| ... | @@ -15,9 +19,14 @@ export interface ResourceTelemetryState { | ... | @@ -15,9 +19,14 @@ export interface ResourceTelemetryState { |
| 15 | readonly retry: () => Promise<ResourceTelemetrySnapshot>; | 19 | readonly retry: () => Promise<ResourceTelemetrySnapshot>; |
| 16 | } | 20 | } |
| 17 | 21 | ||
| 18 | export function useResourceTelemetry(): ResourceTelemetryState { | 22 | export function useResourceTelemetry( |
| 23 | targetEnvironmentId?: EnvironmentId | null, | ||
| 24 | ): ResourceTelemetryState { | ||
| 19 | const primaryEnvironment = usePrimaryEnvironment(); | 25 | const primaryEnvironment = usePrimaryEnvironment(); |
| 20 | const environmentId = primaryEnvironment?.environmentId ?? null; | 26 | const environmentId = |
| 27 | targetEnvironmentId === undefined | ||
| 28 | ? (primaryEnvironment?.environmentId ?? null) | ||
| 29 | : targetEnvironmentId; | ||
| 21 | const query = useEnvironmentQuery( | 30 | const query = useEnvironmentQuery( |
| 22 | environmentId === null | 31 | environmentId === null |
| 23 | ? null | 32 | ? null |
| ... | @@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState { | ... | @@ -40,9 +49,15 @@ export function useResourceTelemetry(): ResourceTelemetryState { |
| 40 | return { ...query, retry }; | 49 | return { ...query, retry }; |
| 41 | } | 50 | } |
| 42 | 51 | ||
| 43 | export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) { | 52 | export function useResourceTelemetryHistory( |
| 53 | input: ResourceTelemetryHistoryInput, | ||
| 54 | targetEnvironmentId?: EnvironmentId | null, | ||
| 55 | ) { | ||
| 44 | const primaryEnvironment = usePrimaryEnvironment(); | 56 | const primaryEnvironment = usePrimaryEnvironment(); |
| 45 | const environmentId = primaryEnvironment?.environmentId ?? null; | 57 | const environmentId = |
| 58 | targetEnvironmentId === undefined | ||
| 59 | ? (primaryEnvironment?.environmentId ?? null) | ||
| 60 | : targetEnvironmentId; | ||
| 46 | return useEnvironmentQuery( | 61 | return useEnvironmentQuery( |
| 47 | environmentId === null | 62 | environmentId === null |
| 48 | ? null | 63 | ? null |
apps/web/src/routes/settings.integrations.tsx+1-5| ... | @@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; | ... | @@ -2,10 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings"; | 3 | import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings"; |
| 4 | 4 | ||
| 5 | function SettingsIntegrationsRoute() { | ||
| 6 | return <IntegrationsSettingsPanel />; | ||
| 7 | } | ||
| 8 | |||
| 9 | export const Route = createFileRoute("/settings/integrations")({ | 5 | export const Route = createFileRoute("/settings/integrations")({ |
| 10 | component: SettingsIntegrationsRoute, | 6 | component: IntegrationsSettingsPanel, |
| 11 | }); | 7 | }); |
apps/web/src/routes/settings.projects.tsx+1-19| ... | @@ -1,24 +1,6 @@ | ... | @@ -1,24 +1,6 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { ProjectsSettings } from "../components/settings/ProjectsSettings"; | 2 | import { ProjectsSettings } from "../components/settings/ProjectsSettings"; |
| 3 | import { validateSettingsScopeSearch } from "../components/settings/settingsScope"; | ||
| 4 | 3 | ||
| 5 | export const Route = createFileRoute("/settings/projects")({ | 4 | export const Route = createFileRoute("/settings/projects")({ |
| 6 | validateSearch: validateSettingsScopeSearch, | 5 | component: ProjectsSettings, |
| 7 | component: ProjectsRoute, | ||
| 8 | }); | 6 | }); |
| 9 | |||
| 10 | function ProjectsRoute() { | ||
| 11 | const search = Route.useSearch(); | ||
| 12 | const navigate = Route.useNavigate(); | ||
| 13 | return ( | ||
| 14 | <ProjectsSettings | ||
| 15 | value={search} | ||
| 16 | onScopeChange={(scope) => { | ||
| 17 | void navigate({ | ||
| 18 | search: scope, | ||
| 19 | replace: true, | ||
| 20 | }); | ||
| 21 | }} | ||
| 22 | /> | ||
| 23 | ); | ||
| 24 | } |
apps/web/src/routes/settings.providers.tsx+23-1| ... | @@ -2,10 +2,32 @@ import { createFileRoute } from "@tanstack/react-router"; | ... | @@ -2,10 +2,32 @@ import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; | 4 | import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; |
| 5 | import { useSettingsScope } from "../components/settings/SettingsScopeContext"; | ||
| 5 | 6 | ||
| 7 | /** | ||
| 8 | * Providers are machine state, so the page shows one environment at a time: | ||
| 9 | * the chosen one, or the representative of the selection. A project crumb | ||
| 10 | * narrows the candidates to the environments that project is registered on. | ||
| 11 | */ | ||
| 6 | function SettingsProvidersRoute() { | 12 | function SettingsProvidersRoute() { |
| 7 | const target = Route.useSearch(); | 13 | const target = Route.useSearch(); |
| 8 | return <ProviderSettingsPanel {...target} />; | 14 | const { environment, scope } = useSettingsScope(); |
| 15 | if (!environment) { | ||
| 16 | return ( | ||
| 17 | <p className="p-8 text-sm text-muted-foreground"> | ||
| 18 | {scope.kind === "environment" | ||
| 19 | ? `Reconnect ${scope.label} to set up its providers.` | ||
| 20 | : "Connect an environment to set up its providers."} | ||
| 21 | </p> | ||
| 22 | ); | ||
| 23 | } | ||
| 24 | return ( | ||
| 25 | <ProviderSettingsPanel | ||
| 26 | environmentId={environment.environmentId} | ||
| 27 | {...(target.instanceId ? { instanceId: target.instanceId } : {})} | ||
| 28 | scoped | ||
| 29 | /> | ||
| 30 | ); | ||
| 9 | } | 31 | } |
| 10 | 32 | ||
| 11 | export const Route = createFileRoute("/settings/providers")({ | 33 | export const Route = createFileRoute("/settings/providers")({ |
apps/web/src/routes/settings.tsx+139-16| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { RotateCcwIcon } from "lucide-react"; | ||
| 2 | import { | 1 | import { |
| 3 | Outlet, | 2 | Outlet, |
| 4 | createFileRoute, | 3 | createFileRoute, |
| ... | @@ -7,18 +6,34 @@ import { | ... | @@ -7,18 +6,34 @@ import { |
| 7 | useLocation, | 6 | useLocation, |
| 8 | useNavigate, | 7 | useNavigate, |
| 9 | } from "@tanstack/react-router"; | 8 | } from "@tanstack/react-router"; |
| 10 | import { useCallback, useEffect, useState } from "react"; | 9 | import { useCallback, useEffect, useState, type ReactNode } from "react"; |
| 11 | 10 | import { RotateCcwIcon } from "lucide-react"; | |
| 11 | import { Button } from "../components/ui/button"; | ||
| 12 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; | 12 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 13 | |||
| 13 | import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; | 14 | import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; |
| 14 | import { Button } from "../components/ui/button"; | ||
| 15 | import { SidebarInset } from "../components/ui/sidebar"; | 15 | import { SidebarInset } from "../components/ui/sidebar"; |
| 16 | import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; | 16 | import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; |
| 17 | import { isElectron } from "../env"; | 17 | import { isElectron } from "../env"; |
| 18 | import { | ||
| 19 | SettingsScopeProvider, | ||
| 20 | useSettingsScope, | ||
| 21 | } from "../components/settings/SettingsScopeContext"; | ||
| 22 | import { useSettingsProjectGroups } from "../components/settings/useSettingsProjectGroups"; | ||
| 23 | import { useEnvironments } from "../state/environments"; | ||
| 24 | import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; | ||
| 25 | import { | ||
| 26 | retainSettingsScope, | ||
| 27 | validateSettingsRouteSearch, | ||
| 28 | } from "../components/settings/settingsScopeNavigation"; | ||
| 29 | import { | ||
| 30 | getSettingsSearchTargetScope, | ||
| 31 | getThreadAutoSettlementSearchAvailability, | ||
| 32 | isSettingsSearchScopeAvailable, | ||
| 33 | } from "../components/settings/settingsSearch"; | ||
| 18 | 34 | ||
| 19 | function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { | 35 | function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) { |
| 20 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); | 36 | const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); |
| 21 | |||
| 22 | return ( | 37 | return ( |
| 23 | <Button | 38 | <Button |
| 24 | size="xs" | 39 | size="xs" |
| ... | @@ -27,18 +42,87 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { | ... | @@ -27,18 +42,87 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { |
| 27 | onClick={() => void restoreDefaults()} | 42 | onClick={() => void restoreDefaults()} |
| 28 | > | 43 | > |
| 29 | <RotateCcwIcon className="mx-1 size-3.5" /> | 44 | <RotateCcwIcon className="mx-1 size-3.5" /> |
| 30 | Restore defaults | 45 | Restore device defaults |
| 31 | </Button> | 46 | </Button> |
| 32 | ); | 47 | ); |
| 33 | } | 48 | } |
| 34 | 49 | ||
| 50 | /** Pages whose every row is saved on this client; the scope selects are hidden there. */ | ||
| 51 | const DEVICE_ONLY_PATHS = new Set([ | ||
| 52 | "/settings/appearance", | ||
| 53 | "/settings/snap-shot", | ||
| 54 | "/settings/connections", | ||
| 55 | ]); | ||
| 56 | |||
| 57 | function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { | ||
| 58 | const { scope, connectedEnvironments } = useSettingsScope(); | ||
| 59 | const { environments } = useEnvironments(); | ||
| 60 | const hash = useLocation({ select: (location) => location.hash }); | ||
| 61 | const searchTarget = getSettingsSearchTargetScope(hash); | ||
| 62 | const autoSettlementAvailability = searchTarget?.requiresThreadAutoSettlement | ||
| 63 | ? getThreadAutoSettlementSearchAvailability(environments, scope) | ||
| 64 | : null; | ||
| 65 | if ( | ||
| 66 | scope.kind !== "unavailable" && | ||
| 67 | searchTarget && | ||
| 68 | autoSettlementAvailability && | ||
| 69 | !autoSettlementAvailability.isTargetAvailable | ||
| 70 | ) { | ||
| 71 | return ( | ||
| 72 | <SettingsScopeNotice | ||
| 73 | target="environment" | ||
| 74 | targetId={hash} | ||
| 75 | eligibleEnvironmentIds={autoSettlementAvailability.eligibleEnvironmentIds} | ||
| 76 | > | ||
| 77 | {autoSettlementAvailability.eligibleEnvironmentIds.length > 0 | ||
| 78 | ? `${searchTarget.title} requires a supporting environment. Choose one to continue.` | ||
| 79 | : `${searchTarget.title} requires a supporting environment. Connect or update an environment to continue.`} | ||
| 80 | </SettingsScopeNotice> | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | if ( | ||
| 84 | scope.kind !== "unavailable" && | ||
| 85 | searchTarget && | ||
| 86 | !isSettingsSearchScopeAvailable(searchTarget.scope, scope.kind) | ||
| 87 | ) { | ||
| 88 | const target = | ||
| 89 | searchTarget.scope === "environment" || | ||
| 90 | searchTarget.scope === "project" || | ||
| 91 | searchTarget.scope === "checkout" | ||
| 92 | ? searchTarget.scope | ||
| 93 | : "all"; | ||
| 94 | return ( | ||
| 95 | <SettingsScopeNotice target={target} targetId={hash}> | ||
| 96 | {`${searchTarget.title} is not available for the selected target. Choose its owning scope to continue.`} | ||
| 97 | </SettingsScopeNotice> | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | // Device-local pages ignore the scope entirely; the project page follows | ||
| 101 | // remembered members while a grouping change replaces its URL key. | ||
| 102 | if (DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") { | ||
| 103 | return children; | ||
| 104 | } | ||
| 105 | if (scope.kind === "unavailable") | ||
| 106 | return <p className="p-8 text-sm text-muted-foreground">{scope.message}</p>; | ||
| 107 | if (scope.kind === "environment" && connectedEnvironments.length === 0) { | ||
| 108 | return ( | ||
| 109 | <p className="p-8 text-sm text-muted-foreground"> | ||
| 110 | Reconnect {scope.label} to change its settings. | ||
| 111 | </p> | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | return children; | ||
| 115 | } | ||
| 116 | |||
| 35 | function SettingsContentLayout() { | 117 | function SettingsContentLayout() { |
| 36 | const location = useLocation(); | 118 | const location = useLocation(); |
| 37 | const navigate = useNavigate(); | 119 | const navigate = useNavigate(); |
| 38 | const canGoBack = useCanGoBack(); | 120 | const canGoBack = useCanGoBack(); |
| 121 | const { search, selectScope } = useSettingsScope(); | ||
| 122 | const groups = useSettingsProjectGroups(); | ||
| 123 | const { environments } = useEnvironments(); | ||
| 39 | const [restoreSignal, setRestoreSignal] = useState(0); | 124 | const [restoreSignal, setRestoreSignal] = useState(0); |
| 40 | const showRestoreDefaults = location.pathname === "/settings/general"; | 125 | const showScope = !DEVICE_ONLY_PATHS.has(location.pathname); |
| 41 | const handleRestored = () => setRestoreSignal((value) => value + 1); | ||
| 42 | const navigateBackWithinApp = useCallback(() => { | 126 | const navigateBackWithinApp = useCallback(() => { |
| 43 | if (canGoBack) { | 127 | if (canGoBack) { |
| 44 | window.history.back(); | 128 | window.history.back(); |
| ... | @@ -73,17 +157,31 @@ function SettingsContentLayout() { | ... | @@ -73,17 +157,31 @@ function SettingsContentLayout() { |
| 73 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | 157 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> |
| 74 | <WorkspacePageHeader electron={isElectron}> | 158 | <WorkspacePageHeader electron={isElectron}> |
| 75 | <div className="flex w-full items-center gap-3"> | 159 | <div className="flex w-full items-center gap-3"> |
| 76 | <SettingsBreadcrumb pathname={location.pathname} /> | 160 | <SettingsBreadcrumb |
| 77 | {showRestoreDefaults ? ( | 161 | pathname={location.pathname} |
| 78 | <div className="ms-auto flex items-center gap-2"> | 162 | scope={ |
| 79 | <RestoreDefaultsButton onRestored={handleRestored} /> | 163 | showScope |
| 164 | ? { value: search, groups, environments, onChange: selectScope } | ||
| 165 | : undefined | ||
| 166 | } | ||
| 167 | /> | ||
| 168 | {location.pathname === "/settings/general" ? ( | ||
| 169 | <div className="ms-auto flex shrink-0 items-center"> | ||
| 170 | <RestoreDeviceDefaultsButton | ||
| 171 | onRestored={() => setRestoreSignal((value) => value + 1)} | ||
| 172 | /> | ||
| 80 | </div> | 173 | </div> |
| 81 | ) : null} | 174 | ) : null} |
| 82 | </div> | 175 | </div> |
| 83 | </WorkspacePageHeader> | 176 | </WorkspacePageHeader> |
| 84 | 177 | ||
| 85 | <div key={restoreSignal} className="min-h-0 flex flex-1 flex-col"> | 178 | <div |
| 86 | <Outlet /> | 179 | key={`${JSON.stringify(search)}:${restoreSignal}`} |
| 180 | className="min-h-0 flex flex-1 flex-col" | ||
| 181 | > | ||
| 182 | <SettingsScopeBoundary pathname={location.pathname}> | ||
| 183 | <Outlet /> | ||
| 184 | </SettingsScopeBoundary> | ||
| 87 | </div> | 185 | </div> |
| 88 | </div> | 186 | </div> |
| 89 | </SidebarInset> | 187 | </SidebarInset> |
| ... | @@ -91,10 +189,35 @@ function SettingsContentLayout() { | ... | @@ -91,10 +189,35 @@ function SettingsContentLayout() { |
| 91 | } | 189 | } |
| 92 | 190 | ||
| 93 | function SettingsRouteLayout() { | 191 | function SettingsRouteLayout() { |
| 94 | return <SettingsContentLayout />; | 192 | const rawSearch = Route.useSearch(); |
| 193 | const navigate = Route.useNavigate(); | ||
| 194 | const pathname = useLocation({ select: (location) => location.pathname }); | ||
| 195 | return ( | ||
| 196 | <SettingsScopeProvider | ||
| 197 | search={rawSearch} | ||
| 198 | onChange={(next) => { | ||
| 199 | // Send every axis so the retain middleware sees an explicit target | ||
| 200 | // even when the choice is "all", which is the absence of a key. | ||
| 201 | void navigate({ | ||
| 202 | to: pathname, | ||
| 203 | search: () => ({ | ||
| 204 | project: next.project, | ||
| 205 | machine: next.machine, | ||
| 206 | checkout: next.checkout, | ||
| 207 | }), | ||
| 208 | hash: "", | ||
| 209 | resetScroll: false, | ||
| 210 | }); | ||
| 211 | }} | ||
| 212 | > | ||
| 213 | <SettingsContentLayout /> | ||
| 214 | </SettingsScopeProvider> | ||
| 215 | ); | ||
| 95 | } | 216 | } |
| 96 | 217 | ||
| 97 | export const Route = createFileRoute("/settings")({ | 218 | export const Route = createFileRoute("/settings")({ |
| 219 | validateSearch: validateSettingsRouteSearch, | ||
| 220 | search: { middlewares: [retainSettingsScope] }, | ||
| 98 | beforeLoad: async ({ context, location }) => { | 221 | beforeLoad: async ({ context, location }) => { |
| 99 | if ( | 222 | if ( |
| 100 | context.authGateState.status !== "authenticated" && | 223 | context.authGateState.status !== "authenticated" && |
apps/web/src/state/server.ts-9| ... | @@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make( | ... | @@ -100,10 +100,6 @@ export const primaryServerAvailableEditorsAtom = Atom.make( |
| 100 | get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, | 100 | get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, |
| 101 | ).pipe(Atom.withLabel("web-primary-server-available-editors")); | 101 | ).pipe(Atom.withLabel("web-primary-server-available-editors")); |
| 102 | 102 | ||
| 103 | export const primaryServerKeybindingsConfigPathAtom = Atom.make( | ||
| 104 | (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, | ||
| 105 | ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); | ||
| 106 | |||
| 107 | const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = []; | 103 | const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = []; |
| 108 | 104 | ||
| 109 | /** | 105 | /** |
| ... | @@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make( | ... | @@ -115,8 +111,3 @@ export const primaryServerEnvironmentThemesAtom = Atom.make( |
| 115 | (get): ReadonlyArray<EnvironmentTheme> => | 111 | (get): ReadonlyArray<EnvironmentTheme> => |
| 116 | get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, | 112 | get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, |
| 117 | ).pipe(Atom.withLabel("web-primary-server-environment-themes")); | 113 | ).pipe(Atom.withLabel("web-primary-server-environment-themes")); |
| 118 | |||
| 119 | export const primaryServerObservabilityAtom = Atom.make( | ||
| 120 | (get): ServerConfig["observability"] | null => | ||
| 121 | get(primaryServerConfigAtom)?.observability ?? null, | ||
| 122 | ).pipe(Atom.withLabel("web-primary-server-observability")); |
docs/internals/overview.md+9| ... | @@ -41,6 +41,15 @@ Provider-specific behavior belongs behind an adapter. Orchestration works with n | ... | @@ -41,6 +41,15 @@ Provider-specific behavior belongs behind an adapter. Orchestration works with n |
| 41 | and events, so adding a provider should not require branches throughout the domain or clients. | 41 | and events, so adding a provider should not require branches throughout the domain or clients. |
| 42 | See [provider constraints](./providers.md). | 42 | See [provider constraints](./providers.md). |
| 43 | 43 | ||
| 44 | ## Settings ownership | ||
| 45 | |||
| 46 | Client preferences stay in the current client; environment defaults and project overrides stay | ||
| 47 | on their owning server. The web and desktop settings target is URL state, resolved against current | ||
| 48 | connections and project membership. An unavailable target must not fall back to another environment. | ||
| 49 | **All environments** is an explicit bulk edit of connected, loaded servers, not a durable global | ||
| 50 | default or a promise to synchronize offline or future environments. Project-group targets similarly | ||
| 51 | select known environment-local checkouts; the group itself does not store inherited defaults. | ||
| 52 | |||
| 44 | ## Durable intent and side effects | 53 | ## Durable intent and side effects |
| 45 | 54 | ||
| 46 | The event log is the source of truth for orchestration state. The | 55 | The event log is the source of truth for orchestration state. The |
docs/user/project-settings.md+42-20| ... | @@ -1,34 +1,56 @@ | ... | @@ -1,34 +1,56 @@ |
| 1 | # Project settings | 1 | # Settings and project overrides |
| 2 | 2 | ||
| 3 | Open **Settings → Projects**. The project and machine pickers start at **All projects** and | 3 | The Settings breadcrumb ends with the environment and project a change applies to. They start |
| 4 | **All machines**. | 4 | at **All environments** and **All projects** and stay selected as you move between categories or |
| 5 | search for a setting. | ||
| 5 | 6 | ||
| 6 | Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. | 7 | Preferences saved on this device, such as appearance, confirmations and browser profiles, always |
| 7 | Select an individual project to override a default. Reset its row to inherit again. Changing a | 8 | show and ignore the selection. Everything else is stored on a server. Choose one environment to |
| 8 | default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence | 9 | edit its settings, or leave **All environments** to edit every connected environment at once. |
| 9 | over machine defaults when the project has no explicit workspace override. | 10 | Offline environments keep their current values; this is a bulk edit, not a synced global default. |
| 10 | 11 | ||
| 11 | Select a machine to limit edits to it. **All machines** writes defaults to connected machines; | 12 | Choose a project to override settings for it on the selected environments. A layers icon beside |
| 12 | offline machines keep their previous values. Mixed values are indicated when selected machines | 13 | each server row's title shows where the value comes from: the built-in default, the environment, |
| 13 | or checkouts disagree. Browser access changes apply when an agent session next starts. | 14 | or a project override. Click it to see that chain on every selected environment. An override can |
| 15 | be reset to inherit again. Settings that cannot be overridden by a project are shown read-only | ||
| 16 | while a project is selected. | ||
| 14 | 17 | ||
| 15 | Project grouping has a client-wide default across machines, with individual checkout overrides. | 18 | When the selected environments disagree, the control shows **Mixed** in place of a value and the |
| 16 | Shared actions apply to inheriting projects; editing a project's actions creates an independent list. | 19 | layers icon turns amber. Picking a value applies it to every selected environment. |
| 17 | Reset that list to use shared actions again. Existing project actions are preserved. | ||
| 18 | 20 | ||
| 19 | Project names, icons, removal, and importing actions from a checkout remain project-specific. | 21 | Changing an environment value never touches a project's own override. When projects override the |
| 20 | When there are several checkouts, the checkout picker selects which actions and grouping to edit. | 22 | setting you are editing, the layers icon counts them and the chain lists each one with its value: |
| 23 | click a project to jump to it, or **Reset all** to make those projects follow the environment | ||
| 24 | again. | ||
| 25 | |||
| 26 | Providers and diagnostics are per machine: they show one environment at a time, the primary | ||
| 27 | one until you pick another. Every other setting fans out to the selection. | ||
| 28 | |||
| 29 | ## Defaults and inheritance | ||
| 30 | |||
| 31 | General contains the model and workspace for new threads. Integrations controls agent browser | ||
| 32 | access. Source Control contains automatic pull, the default pull request merge method and text | ||
| 33 | generation. The same rows edit environment defaults or project overrides depending on the | ||
| 34 | project crumb. | ||
| 35 | |||
| 36 | The Project category, shown while a project is selected, holds the project's name, icon, actions, | ||
| 37 | checkouts and removal. Actions belong to a project: editing them creates the project's own list | ||
| 38 | on each selected environment, and reset returns to the environment's shared list. A project's | ||
| 39 | `t3.json` actions can be imported there. | ||
| 40 | |||
| 41 | For workspace mode, a project's `t3.json` preference applies when the project has no override. | ||
| 42 | Browser access changes apply when an agent session next starts. | ||
| 21 | 43 | ||
| 22 | ## Project icons | 44 | ## Project icons |
| 23 | 45 | ||
| 24 | Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies | 46 | Select the project and open Project to choose an icon, emoji, or image. The choice applies to |
| 25 | to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to | 47 | every checkout in the project group and appears on connected clients. Choose **Automatic** to let |
| 26 | let T3 Code detect an icon again. | 48 | T3 Code detect an icon again. |
| 27 | 49 | ||
| 28 | ## Keep the default branch current | 50 | ## Keep the default branch current |
| 29 | 51 | ||
| 30 | Enable **Automatically pull** to keep the default-branch checkout up to date with its configured | 52 | In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date |
| 31 | upstream. | 53 | with its configured upstream. Choose an environment to set the default or a project to override it. |
| 32 | 54 | ||
| 33 | T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, | 55 | T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, |
| 34 | or local commits. It skips checkouts on another branch or without an upstream. If a checkout has | 56 | or local commits. It skips checkouts on another branch or without an upstream. If a checkout has |
docs/user/thread-sidebar.md+6-4| ... | @@ -86,10 +86,12 @@ settlement, but an old closed or merged pull request does not settle work you | ... | @@ -86,10 +86,12 @@ settlement, but an old closed or merged pull request does not settle work you |
| 86 | resumed after it closed. | 86 | resumed after it closed. |
| 87 | 87 | ||
| 88 | Change these rules in **Settings → General**. They continue to run when your apps | 88 | Change these rules in **Settings → General**. They continue to run when your apps |
| 89 | are closed. Changes apply to connected environments that support shared settings; | 89 | are closed. On web and desktop, choose an environment at the top to change only |
| 90 | offline environments and older servers keep their previous values. If connected | 90 | its rules, or **All environments** to update connected environments together. |
| 91 | environments disagree, **Apply to all** copies your current settings to those named | 91 | Mixed values show where the selected environments disagree. Mobile applies these |
| 92 | in the warning. Changing a rule does not reopen already settled threads. | 92 | rules to connected environments that support shared settings. Offline environments |
| 93 | and older servers keep their previous values. Changing a rule does not reopen | ||
| 94 | already settled threads. | ||
| 93 | 95 | ||
| 94 | ## Link a pull request | 96 | ## Link a pull request |
| 95 | 97 |