| author | |
| committer | |
| log | 8efd4e95fcb08478c9c6e8eee384bfca559f62cc |
| tree | f1121414eebd262904d3b85629c160ab84de9bb4 |
| parent | 5014e5fcdd6e5f4a53d0fea4cbecba733821a854 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>10 files changed, 489 insertions(+), 44 deletions(-)
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+128-33| ... | @@ -19,7 +19,7 @@ import { | ... | @@ -19,7 +19,7 @@ import { |
| 19 | squashAtomCommandFailure, | 19 | squashAtomCommandFailure, |
| 20 | } from "@t3tools/client-runtime/state/runtime"; | 20 | } from "@t3tools/client-runtime/state/runtime"; |
| 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 21 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 22 | import { AppText as Text } from "../../components/AppText"; | 22 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 23 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; | 23 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; |
| 24 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; | 24 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; |
| 25 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; | 25 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; |
| ... | @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; | ... | @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; |
| 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 37 | import { serverEnvironment } from "../../state/server"; | 37 | import { serverEnvironment } from "../../state/server"; |
| 38 | import { useAtomCommand } from "../../state/use-atom-command"; | 38 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 39 | import type { EnvironmentId } from "@t3tools/contracts"; | 39 | import { useEnvironments } from "../../state/environments"; |
| 40 | import { | ||
| 41 | DEFAULT_SERVER_SETTINGS, | ||
| 42 | MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 43 | MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| 44 | type ServerSettingsPatch, | ||
| 45 | } from "@t3tools/contracts"; | ||
| 46 | import { | ||
| 47 | findSharedSettingsMismatches, | ||
| 48 | pickSharedServerSettings, | ||
| 49 | } from "@t3tools/client-runtime/state/shared-settings"; | ||
| 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 50 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | import { | 51 | import { |
| 42 | type AppUpdateCheckState, | 52 | type AppUpdateCheckState, |
| ... | @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { |
| 530 | } | 540 | } |
| 531 | 541 | ||
| 532 | function GeneralSettingsSection() { | 542 | function GeneralSettingsSection() { |
| 533 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 534 | const connections = Object.values(savedConnectionsById).sort((left, right) => | ||
| 535 | left.environmentLabel.localeCompare(right.environmentLabel), | ||
| 536 | ); | ||
| 537 | |||
| 538 | return ( | 543 | return ( |
| 539 | <SettingsSection title="General"> | 544 | <SettingsSection title="General"> |
| 540 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> | 545 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 541 | {connections.map((connection) => ( | 546 | <AutoSettleSettingsRows /> |
| 542 | <EnvironmentAutoSettleSwitch | ||
| 543 | key={connection.environmentId} | ||
| 544 | environmentId={connection.environmentId} | ||
| 545 | environmentLabel={connection.environmentLabel} | ||
| 546 | /> | ||
| 547 | ))} | ||
| 548 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> | 547 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 549 | </SettingsSection> | 548 | </SettingsSection> |
| 550 | ); | 549 | ); |
| 551 | } | 550 | } |
| 552 | 551 | ||
| 553 | function EnvironmentAutoSettleSwitch(props: { | 552 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; |
| 554 | readonly environmentId: EnvironmentId; | 553 | |
| 555 | readonly environmentLabel: string; | 554 | /** |
| 556 | }) { | 555 | * Auto-settlement is a user preference that every server has to hold. Mobile |
| 557 | const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); | 556 | * has no primary environment, so the first connected environment that |
| 558 | const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); | 557 | * supports it is the reference value. Edits fan out to every connected |
| 558 | * environment, and a mismatch row lets the user push the reference out. | ||
| 559 | */ | ||
| 560 | function AutoSettleSettingsRows() { | ||
| 561 | const { environments } = useEnvironments(); | ||
| 559 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { | 562 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 560 | label: "auto-settle settings update", | 563 | label: "server settings update", |
| 561 | reportFailure: true, | 564 | reportFailure: true, |
| 562 | }); | 565 | }); |
| 563 | if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { | 566 | |
| 567 | const connected = environments.filter( | ||
| 568 | (environment) => | ||
| 569 | environment.connection.phase === "connected" && | ||
| 570 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, | ||
| 571 | ); | ||
| 572 | const reference = connected[0] ?? null; | ||
| 573 | const referenceSettings = reference?.serverConfig?.settings ?? null; | ||
| 574 | |||
| 575 | const [daysDraft, setDaysDraft] = useState<string | null>(null); | ||
| 576 | |||
| 577 | if (reference === null || referenceSettings === null) { | ||
| 564 | return null; | 578 | return null; |
| 565 | } | 579 | } |
| 580 | |||
| 581 | const writeToAll = (patch: ServerSettingsPatch) => { | ||
| 582 | for (const environment of connected) { | ||
| 583 | void updateSettings({ environmentId: environment.environmentId, input: { patch } }); | ||
| 584 | } | ||
| 585 | }; | ||
| 586 | |||
| 587 | const mismatches = findSharedSettingsMismatches({ | ||
| 588 | primaryEnvironmentId: reference.environmentId, | ||
| 589 | primarySettings: referenceSettings, | ||
| 590 | environments: environments.map((environment) => ({ | ||
| 591 | environmentId: environment.environmentId, | ||
| 592 | label: environment.label, | ||
| 593 | connected: environment.connection.phase === "connected", | ||
| 594 | settings: environment.serverConfig?.settings ?? null, | ||
| 595 | })), | ||
| 596 | }); | ||
| 597 | |||
| 598 | const afterDays = referenceSettings.sidebarAutoSettleAfterDays; | ||
| 599 | const commitDays = () => { | ||
| 600 | const draft = (daysDraft ?? "").trim(); | ||
| 601 | setDaysDraft(null); | ||
| 602 | // Whole-string check so "3.5" and "3days" are rejected instead of | ||
| 603 | // silently becoming 3 on every connected environment. | ||
| 604 | const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; | ||
| 605 | if ( | ||
| 606 | Number.isInteger(parsed) && | ||
| 607 | parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | ||
| 608 | parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && | ||
| 609 | parsed !== afterDays | ||
| 610 | ) { | ||
| 611 | writeToAll({ sidebarAutoSettleAfterDays: parsed }); | ||
| 612 | } | ||
| 613 | }; | ||
| 614 | |||
| 566 | return ( | 615 | return ( |
| 567 | <SettingsSwitchRow | 616 | <> |
| 568 | icon="arrow.triangle.branch" | 617 | <SettingsSwitchRow |
| 569 | label={`Auto-settle merged threads · ${props.environmentLabel}`} | 618 | icon="arrow.triangle.branch" |
| 570 | value={settings?.sidebarAutoSettleOnMerge ?? true} | 619 | label="Auto-settle merged threads" |
| 571 | onValueChange={(value) => { | 620 | value={referenceSettings.sidebarAutoSettleOnMerge} |
| 572 | void updateSettings({ | 621 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} |
| 573 | environmentId: props.environmentId, | 622 | /> |
| 574 | input: { patch: { sidebarAutoSettleOnMerge: value } }, | 623 | <SettingsSwitchRow |
| 575 | }); | 624 | icon="clock" |
| 576 | }} | 625 | label="Auto-settle inactive threads" |
| 577 | /> | 626 | subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`} |
| 627 | value={afterDays !== null} | ||
| 628 | onValueChange={(value) => | ||
| 629 | writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) | ||
| 630 | } | ||
| 631 | /> | ||
| 632 | {afterDays !== null ? ( | ||
| 633 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | ||
| 634 | <Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text> | ||
| 635 | <TextInput | ||
| 636 | className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base" | ||
| 637 | keyboardType="number-pad" | ||
| 638 | returnKeyType="done" | ||
| 639 | value={daysDraft ?? String(afterDays)} | ||
| 640 | onChangeText={setDaysDraft} | ||
| 641 | onBlur={commitDays} | ||
| 642 | onSubmitEditing={commitDays} | ||
| 643 | accessibilityLabel="Days before auto-settle" | ||
| 644 | /> | ||
| 645 | </View> | ||
| 646 | ) : null} | ||
| 647 | {mismatches.length > 0 ? ( | ||
| 648 | <View className="flex-row items-center gap-4 border-t border-border-subtle p-4"> | ||
| 649 | <View className="min-w-0 flex-1"> | ||
| 650 | <Text className="text-lg text-foreground">Settings differ</Text> | ||
| 651 | <Text className="text-sm text-foreground-muted"> | ||
| 652 | {mismatches.map((mismatch) => mismatch.label).join(", ")} | ||
| 653 | </Text> | ||
| 654 | </View> | ||
| 655 | <Pressable | ||
| 656 | accessibilityRole="button" | ||
| 657 | onPress={() => { | ||
| 658 | const patch = pickSharedServerSettings(referenceSettings); | ||
| 659 | for (const mismatch of mismatches) { | ||
| 660 | void updateSettings({ | ||
| 661 | environmentId: mismatch.environmentId, | ||
| 662 | input: { patch }, | ||
| 663 | }); | ||
| 664 | } | ||
| 665 | }} | ||
| 666 | className="rounded-full bg-subtle px-4 py-2 active:opacity-70" | ||
| 667 | > | ||
| 668 | <Text className="text-base font-t3-medium text-foreground">Apply to all</Text> | ||
| 669 | </Pressable> | ||
| 670 | </View> | ||
| 671 | ) : null} | ||
| 672 | </> | ||
| 578 | ); | 673 | ); |
| 579 | } | 674 | } |
| 580 | 675 |
apps/web/src/components/settings/SettingsPanels.tsx+2| ... | @@ -112,6 +112,7 @@ import { | ... | @@ -112,6 +112,7 @@ import { |
| 112 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, | 112 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 113 | } from "../../appearanceFonts"; | 113 | } from "../../appearanceFonts"; |
| 114 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; | 114 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 115 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | ||
| 115 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; | 116 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; |
| 116 | import { | 117 | import { |
| 117 | NumberField, | 118 | NumberField, |
| ... | @@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { | ... | @@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { |
| 1928 | 1929 | ||
| 1929 | return ( | 1930 | return ( |
| 1930 | <SettingsPageContainer> | 1931 | <SettingsPageContainer> |
| 1932 | <SharedSettingsMismatchAlert /> | ||
| 1931 | <SettingsSection title="General"> | 1933 | <SettingsSection title="General"> |
| 1932 | <SettingsRow | 1934 | <SettingsRow |
| 1933 | {...searchableSetting("project-grouping")} | 1935 | {...searchableSetting("project-grouping")} |
apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx created+32| ... | @@ -0,0 +1,32 @@ | ||
| 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="compact" onClick={applyToAll}> | ||
| 27 | Apply to all | ||
| 28 | </Button> | ||
| 29 | </AlertAction> | ||
| 30 | </Alert> | ||
| 31 | ); | ||
| 32 | } | ||
apps/web/src/components/settings/SourceControlSettings.tsx+2| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | } from "@t3tools/shared/backgroundActivitySettings"; | 18 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 19 | 19 | ||
| 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 21 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | ||
| 21 | import { cn } from "../../lib/utils"; | 22 | import { cn } from "../../lib/utils"; |
| 22 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; | 23 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| 23 | import { useEnvironmentQuery } from "../../state/query"; | 24 | import { useEnvironmentQuery } from "../../state/query"; |
| ... | @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { | ... | @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { |
| 548 | 549 | ||
| 549 | return ( | 550 | return ( |
| 550 | <SettingsPageContainer> | 551 | <SettingsPageContainer> |
| 552 | <SharedSettingsMismatchAlert /> | ||
| 551 | {isInitialScanPending ? ( | 553 | {isInitialScanPending ? ( |
| 552 | <> | 554 | <> |
| 553 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> | 555 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> |
apps/web/src/hooks/useSettings.ts+109-5| ... | @@ -25,6 +25,11 @@ import { | ... | @@ -25,6 +25,11 @@ import { |
| 25 | type UnifiedSettings, | 25 | type UnifiedSettings, |
| 26 | } from "@t3tools/contracts/settings"; | 26 | } from "@t3tools/contracts/settings"; |
| 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 28 | import { | ||
| 29 | findSharedSettingsMismatches, | ||
| 30 | pickSharedServerSettings, | ||
| 31 | splitSharedServerPatch, | ||
| 32 | } from "@t3tools/client-runtime/state/shared-settings"; | ||
| 28 | import { ensureLocalApi } from "~/localApi"; | 33 | import { ensureLocalApi } from "~/localApi"; |
| 29 | import { | 34 | import { |
| 30 | getThemeDefinition, | 35 | getThemeDefinition, |
| ... | @@ -35,7 +40,11 @@ import { | ... | @@ -35,7 +40,11 @@ import { |
| 35 | } from "~/themePalette"; | 40 | } from "~/themePalette"; |
| 36 | import * as Struct from "effect/Struct"; | 41 | import * as Struct from "effect/Struct"; |
| 37 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 42 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 38 | import { usePrimaryEnvironment } from "~/state/environments"; | 43 | import { |
| 44 | type EnvironmentPresentation, | ||
| 45 | useEnvironments, | ||
| 46 | usePrimaryEnvironment, | ||
| 47 | } from "~/state/environments"; | ||
| 39 | import { useAtomCommand } from "~/state/use-atom-command"; | 48 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 40 | import { useTheme } from "./useTheme"; | 49 | import { useTheme } from "./useTheme"; |
| 41 | 50 | ||
| ... | @@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>( | ... | @@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>( |
| 307 | return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); | 316 | return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); |
| 308 | } | 317 | } |
| 309 | 318 | ||
| 319 | /** | ||
| 320 | * Whether an environment can hold every shared key right now. Gated on the | ||
| 321 | * auto-settlement capability because it is the newest of the shared keys: a | ||
| 322 | * server that has it has all of them. Older servers drop unknown keys on | ||
| 323 | * write, so a mismatch against them could never clear, and their decoded | ||
| 324 | * defaults must not be treated as real values. | ||
| 325 | */ | ||
| 326 | function supportsSharedSettings(environment: EnvironmentPresentation): boolean { | ||
| 327 | return ( | ||
| 328 | environment.connection.phase === "connected" && | ||
| 329 | environment.serverConfig?.environment.capabilities.threadAutoSettlement === true | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | |||
| 333 | /** Environments that can receive a shared settings write right now. */ | ||
| 334 | function useConnectedEnvironmentIds(): ReadonlyArray<EnvironmentId> { | ||
| 335 | const { environments } = useEnvironments(); | ||
| 336 | return useMemo( | ||
| 337 | () => | ||
| 338 | environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), | ||
| 339 | [environments], | ||
| 340 | ); | ||
| 341 | } | ||
| 342 | |||
| 310 | /** | 343 | /** |
| 311 | * Returns an updater that routes each key to the correct backing store. | 344 | * Returns an updater that routes each key to the correct backing store. |
| 312 | * | 345 | * |
| 313 | * Server keys are optimistically patched in atom-backed server state, then | 346 | * Server keys are optimistically patched in atom-backed server state, then |
| 314 | * persisted via RPC. Client keys go through client persistence. | 347 | * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) |
| 348 | * are written to every connected environment, not only the target, so a user | ||
| 349 | * preference does not silently drift between machines. Client keys go through | ||
| 350 | * client persistence. | ||
| 315 | */ | 351 | */ |
| 316 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | 352 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 317 | const persistServerSettings = useAtomCommand( | 353 | const persistServerSettings = useAtomCommand( |
| 318 | serverEnvironment.updateSettings, | 354 | serverEnvironment.updateSettings, |
| 319 | "server settings update", | 355 | "server settings update", |
| 320 | ); | 356 | ); |
| 357 | const connectedEnvironmentIds = useConnectedEnvironmentIds(); | ||
| 321 | const updateSettings = useCallback( | 358 | const updateSettings = useCallback( |
| 322 | (patch: UnifiedSettingsPatch) => { | 359 | (patch: UnifiedSettingsPatch) => { |
| 323 | const { serverPatch, clientPatch } = splitPatch(patch); | 360 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 324 | 361 | ||
| 325 | if (Object.keys(serverPatch).length > 0) { | 362 | if (Object.keys(serverPatch).length > 0) { |
| 326 | if (environmentId) { | 363 | const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); |
| 364 | if (environmentId && Object.keys(localPatch).length > 0) { | ||
| 327 | void persistServerSettings({ | 365 | void persistServerSettings({ |
| 328 | environmentId, | 366 | environmentId, |
| 329 | input: { patch: serverPatch }, | 367 | input: { patch: localPatch }, |
| 330 | }); | 368 | }); |
| 331 | } | 369 | } |
| 370 | if (Object.keys(sharedPatch).length > 0) { | ||
| 371 | const targets = new Set(connectedEnvironmentIds); | ||
| 372 | if (environmentId) { | ||
| 373 | targets.add(environmentId); | ||
| 374 | } | ||
| 375 | for (const targetId of targets) { | ||
| 376 | void persistServerSettings({ | ||
| 377 | environmentId: targetId, | ||
| 378 | input: { patch: sharedPatch }, | ||
| 379 | }); | ||
| 380 | } | ||
| 381 | } | ||
| 332 | } | 382 | } |
| 333 | if (Object.keys(clientPatch).length > 0) { | 383 | if (Object.keys(clientPatch).length > 0) { |
| 334 | persistClientSettings({ | 384 | persistClientSettings({ |
| ... | @@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -337,12 +387,66 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 337 | }); | 387 | }); |
| 338 | } | 388 | } |
| 339 | }, | 389 | }, |
| 340 | [environmentId, persistServerSettings], | 390 | [connectedEnvironmentIds, environmentId, persistServerSettings], |
| 341 | ); | 391 | ); |
| 342 | 392 | ||
| 343 | return updateSettings; | 393 | return updateSettings; |
| 344 | } | 394 | } |
| 345 | 395 | ||
| 396 | /** | ||
| 397 | * Connected environments whose shared settings differ from the primary's, | ||
| 398 | * plus an action that writes the primary's values to all of them. Drift | ||
| 399 | * happens when an environment was offline during an edit or was changed by | ||
| 400 | * an older client. | ||
| 401 | */ | ||
| 402 | export function useSharedSettingsSync() { | ||
| 403 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 404 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | ||
| 405 | // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls | ||
| 406 | // back to defaults while the primary is disconnected, and "apply to all" | ||
| 407 | // must never push defaults over real values. Same for a primary too old to | ||
| 408 | // hold the shared keys: its decoded defaults are not a source of truth. | ||
| 409 | const primarySettings = | ||
| 410 | primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) | ||
| 411 | ? (primaryEnvironment.serverConfig?.settings ?? null) | ||
| 412 | : null; | ||
| 413 | const { environments } = useEnvironments(); | ||
| 414 | const persistServerSettings = useAtomCommand( | ||
| 415 | serverEnvironment.updateSettings, | ||
| 416 | "server settings update", | ||
| 417 | ); | ||
| 418 | |||
| 419 | const mismatches = useMemo( | ||
| 420 | () => | ||
| 421 | findSharedSettingsMismatches({ | ||
| 422 | primaryEnvironmentId, | ||
| 423 | primarySettings, | ||
| 424 | environments: environments.map((environment) => ({ | ||
| 425 | environmentId: environment.environmentId, | ||
| 426 | label: environment.label, | ||
| 427 | connected: supportsSharedSettings(environment), | ||
| 428 | settings: environment.serverConfig?.settings ?? null, | ||
| 429 | })), | ||
| 430 | }), | ||
| 431 | [environments, primaryEnvironmentId, primarySettings], | ||
| 432 | ); | ||
| 433 | |||
| 434 | const applyToAll = useCallback(() => { | ||
| 435 | if (primarySettings === null) { | ||
| 436 | return; | ||
| 437 | } | ||
| 438 | const patch = pickSharedServerSettings(primarySettings); | ||
| 439 | for (const mismatch of mismatches) { | ||
| 440 | void persistServerSettings({ | ||
| 441 | environmentId: mismatch.environmentId, | ||
| 442 | input: { patch }, | ||
| 443 | }); | ||
| 444 | } | ||
| 445 | }, [mismatches, persistServerSettings, primarySettings]); | ||
| 446 | |||
| 447 | return { mismatches, applyToAll }; | ||
| 448 | } | ||
| 449 | |||
| 346 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { | 450 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { |
| 347 | return useUpdateSettingsTarget(environmentId); | 451 | return useUpdateSettingsTarget(environmentId); |
| 348 | } | 452 | } |
docs/internals/overview.md+4-1| ... | @@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by | ... | @@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by |
| 88 | `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later | 88 | `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later |
| 89 | does not define turn end. | 89 | does not define turn end. |
| 90 | 90 | ||
| 91 | Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. | 91 | Thread settlement is server-owned. Each server's own settings control PR and inactivity |
| 92 | settlement. Those keys are user preferences, so clients write them to every connected environment | ||
| 93 | (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn | ||
| 94 | when a connected environment drifts. | ||
| 92 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and | 95 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and |
| 93 | once per minute, including when no client is connected. It dispatches the guarded internal | 96 | once per minute, including when no client is connected. It dispatches the guarded internal |
| 94 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic | 97 | `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic |
docs/user/thread-sidebar.md+11-5| ... | @@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p | ... | @@ -10,16 +10,22 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p |
| 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull | 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 11 | request merges if **Auto-settle merged threads** is enabled. | 11 | request merges if **Auto-settle merged threads** is enabled. |
| 12 | 12 | ||
| 13 | Each environment owns its automatic settlement settings. The server checks them even when no web, | 13 | Each server stores its own copy of the automatic settlement settings and checks them even when no |
| 14 | desktop, or mobile client is connected. By default, it settles threads after three days without | 14 | web, desktop, or mobile client is connected. By default, it settles threads after three days without |
| 15 | activity and when their pull request merges. An eligible idle thread also settles when its pull | 15 | activity and when their pull request merges. An eligible idle thread also settles when its pull |
| 16 | request closes. An open pull request blocks inactivity settlement. Active work, pending input, and | 16 | request closes. An open pull request blocks inactivity settlement. Active work, pending input, and |
| 17 | live background work keep the thread active. T3 Code settles from a closed or merged pull request | 17 | live background work keep the thread active. T3 Code settles from a closed or merged pull request |
| 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not | 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not |
| 19 | available, the inactivity rule still applies. A manual un-settle also keeps the thread active. | 19 | available, the inactivity rule still applies. A manual un-settle also keeps the thread active. |
| 20 | Change these rules in **Settings > General** for the environment. A settings change affects future | 20 | |
| 21 | settlement and does not reopen a settled thread. Settings saved by older clients on one device no | 21 | Change these rules in **Settings > General**. The change is written to every environment you are |
| 22 | longer control this behavior. | 22 | connected to at that moment. An environment that is offline keeps its old value. When a connected |
| 23 | environment holds a different value, **Settings > General** shows a warning that names it. Choose | ||
| 24 | **Apply to all** to write your current values to every connected environment. The same applies to | ||
| 25 | the new-thread workspace mode and the source control writing style. | ||
| 26 | |||
| 27 | A settings change affects future settlement and does not reopen a settled thread. Settings saved | ||
| 28 | by older clients on one device no longer control this behavior. | ||
| 23 | 29 | ||
| 24 | When you un-settle a thread, it returns to the top of the active list so you can find it right | 30 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 25 | away. Its timestamps do not change. Other threads keep their positions. | 31 | away. Its timestamps do not change. Other threads keep their positions. |
packages/client-runtime/package.json+4| ... | @@ -147,6 +147,10 @@ | ... | @@ -147,6 +147,10 @@ |
| 147 | "types": "./src/state/session.ts", | 147 | "types": "./src/state/session.ts", |
| 148 | "default": "./src/state/session.ts" | 148 | "default": "./src/state/session.ts" |
| 149 | }, | 149 | }, |
| 150 | "./state/shared-settings": { | ||
| 151 | "types": "./src/state/sharedSettings.ts", | ||
| 152 | "default": "./src/state/sharedSettings.ts" | ||
| 153 | }, | ||
| 150 | "./state/shell": { | 154 | "./state/shell": { |
| 151 | "types": "./src/state/shell.ts", | 155 | "types": "./src/state/shell.ts", |
| 152 | "default": "./src/state/shell.ts" | 156 | "default": "./src/state/shell.ts" |
packages/client-runtime/src/state/sharedSettings.test.ts created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | findSharedSettingsMismatches, | ||
| 6 | pickSharedServerSettings, | ||
| 7 | splitSharedServerPatch, | ||
| 8 | } from "./sharedSettings.ts"; | ||
| 9 | |||
| 10 | const primaryId = EnvironmentId.make("env-primary"); | ||
| 11 | const laptopId = EnvironmentId.make("env-laptop"); | ||
| 12 | const boxId = EnvironmentId.make("env-box"); | ||
| 13 | |||
| 14 | describe("splitSharedServerPatch", () => { | ||
| 15 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { | ||
| 16 | const { sharedPatch, localPatch } = splitSharedServerPatch({ | ||
| 17 | sidebarAutoSettleAfterDays: 7, | ||
| 18 | sidebarAutoSettleOnMerge: false, | ||
| 19 | enableAgentBrowserAccess: false, | ||
| 20 | }); | ||
| 21 | expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); | ||
| 22 | expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); | ||
| 23 | }); | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("pickSharedServerSettings", () => { | ||
| 27 | it("returns only the shared keys", () => { | ||
| 28 | expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ | ||
| 29 | "defaultThreadEnvMode", | ||
| 30 | "newWorktreesStartFromOrigin", | ||
| 31 | "sidebarAutoSettleAfterDays", | ||
| 32 | "sidebarAutoSettleOnMerge", | ||
| 33 | "sourceControlWritingStyle", | ||
| 34 | ]); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | describe("findSharedSettingsMismatches", () => { | ||
| 39 | const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; | ||
| 40 | |||
| 41 | it("lists connected environments whose shared settings differ", () => { | ||
| 42 | const mismatches = findSharedSettingsMismatches({ | ||
| 43 | primaryEnvironmentId: primaryId, | ||
| 44 | primarySettings, | ||
| 45 | environments: [ | ||
| 46 | { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, | ||
| 47 | { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, | ||
| 48 | { | ||
| 49 | environmentId: boxId, | ||
| 50 | label: "Remote Box", | ||
| 51 | connected: true, | ||
| 52 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | }); | ||
| 56 | expect(mismatches).toEqual([{ environmentId: boxId, label: "Remote Box" }]); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("ignores machine-only differences", () => { | ||
| 60 | const mismatches = findSharedSettingsMismatches({ | ||
| 61 | primaryEnvironmentId: primaryId, | ||
| 62 | primarySettings, | ||
| 63 | environments: [ | ||
| 64 | { | ||
| 65 | environmentId: boxId, | ||
| 66 | label: "Remote Box", | ||
| 67 | connected: true, | ||
| 68 | settings: { ...primarySettings, enableAgentBrowserAccess: false }, | ||
| 69 | }, | ||
| 70 | ], | ||
| 71 | }); | ||
| 72 | expect(mismatches).toEqual([]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("reports nothing until the primary environment's settings are loaded", () => { | ||
| 76 | const environments = [ | ||
| 77 | { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, | ||
| 78 | ]; | ||
| 79 | expect( | ||
| 80 | findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), | ||
| 81 | ).toEqual([]); | ||
| 82 | expect( | ||
| 83 | findSharedSettingsMismatches({ | ||
| 84 | primaryEnvironmentId: primaryId, | ||
| 85 | primarySettings: null, | ||
| 86 | environments, | ||
| 87 | }), | ||
| 88 | ).toEqual([]); | ||
| 89 | }); | ||
| 90 | |||
| 91 | it("skips offline environments and environments without a loaded config", () => { | ||
| 92 | const mismatches = findSharedSettingsMismatches({ | ||
| 93 | primaryEnvironmentId: primaryId, | ||
| 94 | primarySettings, | ||
| 95 | environments: [ | ||
| 96 | { | ||
| 97 | environmentId: laptopId, | ||
| 98 | label: "Laptop", | ||
| 99 | connected: false, | ||
| 100 | settings: DEFAULT_SERVER_SETTINGS, | ||
| 101 | }, | ||
| 102 | { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, | ||
| 103 | ], | ||
| 104 | }); | ||
| 105 | expect(mismatches).toEqual([]); | ||
| 106 | }); | ||
| 107 | }); | ||
packages/client-runtime/src/state/sharedSettings.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | /** | ||
| 2 | * Shared server settings. | ||
| 3 | * | ||
| 4 | * Every server keeps its own `settings.json`, but some keys are user | ||
| 5 | * preferences that only live on the server because the server has to act on | ||
| 6 | * them (auto-settlement runs with no client attached). A user does not want | ||
| 7 | * those to differ per machine. Clients write these keys to every connected | ||
| 8 | * environment, and warn when a connected environment still holds a different | ||
| 9 | * value so the user can push their current value out. | ||
| 10 | */ | ||
| 11 | import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | ||
| 12 | import * as Equal from "effect/Equal"; | ||
| 13 | import * as Struct from "effect/Struct"; | ||
| 14 | |||
| 15 | /** Server keys that hold a user preference rather than machine config. */ | ||
| 16 | export const SHARED_SERVER_SETTING_KEYS = [ | ||
| 17 | "sidebarAutoSettleAfterDays", | ||
| 18 | "sidebarAutoSettleOnMerge", | ||
| 19 | "defaultThreadEnvMode", | ||
| 20 | "newWorktreesStartFromOrigin", | ||
| 21 | "sourceControlWritingStyle", | ||
| 22 | ] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>; | ||
| 23 | |||
| 24 | export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; | ||
| 25 | |||
| 26 | const SHARED_KEY_SET = new Set<string>(SHARED_SERVER_SETTING_KEYS); | ||
| 27 | |||
| 28 | /** Split a server patch into the keys every environment should receive and the primary-only rest. */ | ||
| 29 | export function splitSharedServerPatch(patch: ServerSettingsPatch): { | ||
| 30 | sharedPatch: ServerSettingsPatch; | ||
| 31 | localPatch: ServerSettingsPatch; | ||
| 32 | } { | ||
| 33 | const sharedPatch: Record<string, unknown> = {}; | ||
| 34 | const localPatch: Record<string, unknown> = {}; | ||
| 35 | for (const [key, value] of Object.entries(patch)) { | ||
| 36 | if (SHARED_KEY_SET.has(key)) { | ||
| 37 | sharedPatch[key] = value; | ||
| 38 | } else { | ||
| 39 | localPatch[key] = value; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | return { | ||
| 43 | sharedPatch: sharedPatch as ServerSettingsPatch, | ||
| 44 | localPatch: localPatch as ServerSettingsPatch, | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 48 | /** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ | ||
| 49 | export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { | ||
| 50 | return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); | ||
| 51 | } | ||
| 52 | |||
| 53 | export interface SharedSettingsEnvironment { | ||
| 54 | readonly environmentId: EnvironmentId; | ||
| 55 | readonly label: string; | ||
| 56 | readonly connected: boolean; | ||
| 57 | readonly settings: ServerSettings | null; | ||
| 58 | } | ||
| 59 | |||
| 60 | /** | ||
| 61 | * Connected environments whose shared settings differ from the primary | ||
| 62 | * environment's. Offline environments are skipped: nothing can be read from | ||
| 63 | * or written to them, and the warning would never clear. With no primary | ||
| 64 | * settings loaded there is nothing to compare against, so nothing is | ||
| 65 | * reported. Callers must pass the real loaded settings, never a default | ||
| 66 | * fallback, or "apply to all" would push defaults over real values. | ||
| 67 | */ | ||
| 68 | export function findSharedSettingsMismatches(input: { | ||
| 69 | readonly primaryEnvironmentId: EnvironmentId | null; | ||
| 70 | readonly primarySettings: ServerSettings | null; | ||
| 71 | readonly environments: ReadonlyArray<SharedSettingsEnvironment>; | ||
| 72 | }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { | ||
| 73 | if (input.primaryEnvironmentId === null || input.primarySettings === null) { | ||
| 74 | return []; | ||
| 75 | } | ||
| 76 | const expected = pickSharedServerSettings(input.primarySettings); | ||
| 77 | return input.environments.flatMap((environment) => { | ||
| 78 | if ( | ||
| 79 | environment.environmentId === input.primaryEnvironmentId || | ||
| 80 | !environment.connected || | ||
| 81 | environment.settings === null | ||
| 82 | ) { | ||
| 83 | return []; | ||
| 84 | } | ||
| 85 | const actual = pickSharedServerSettings(environment.settings); | ||
| 86 | return Equal.equals(actual, expected) | ||
| 87 | ? [] | ||
| 88 | : [{ environmentId: environment.environmentId, label: environment.label }]; | ||
| 89 | }); | ||
| 90 | } | ||