| 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 | 19 | squashAtomCommandFailure, |
| 20 | 20 | } from "@t3tools/client-runtime/state/runtime"; |
| 21 | 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 | 23 | import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; |
| 24 | 24 | import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; |
| 25 | 25 | import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; |
| ... | ... | @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; |
| 36 | 36 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 37 | 37 | import { serverEnvironment } from "../../state/server"; |
| 38 | 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 | 50 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | 51 | import { |
| 42 | 52 | type AppUpdateCheckState, |
| ... | ... | @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { |
| 530 | 540 | } |
| 531 | 541 | |
| 532 | 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 | 543 | return ( |
| 539 | 544 | <SettingsSection title="General"> |
| 540 | 545 | <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" /> |
| 541 | {connections.map((connection) => ( | |
| 542 | <EnvironmentAutoSettleSwitch | |
| 543 | key={connection.environmentId} | |
| 544 | environmentId={connection.environmentId} | |
| 545 | environmentLabel={connection.environmentLabel} | |
| 546 | /> | |
| 547 | ))} | |
| 546 | <AutoSettleSettingsRows /> | |
| 548 | 547 | <SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" /> |
| 549 | 548 | </SettingsSection> |
| 550 | 549 | ); |
| 551 | 550 | } |
| 552 | 551 | |
| 553 | function EnvironmentAutoSettleSwitch(props: { | |
| 554 | readonly environmentId: EnvironmentId; | |
| 555 | readonly environmentLabel: string; | |
| 556 | }) { | |
| 557 | const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); | |
| 558 | const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); | |
| 552 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; | |
| 553 | ||
| 554 | /** | |
| 555 | * Auto-settlement is a user preference that every server has to hold. Mobile | |
| 556 | * has no primary environment, so the first connected environment that | |
| 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 | 562 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 560 | label: "auto-settle settings update", | |
| 563 | label: "server settings update", | |
| 561 | 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 | 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 | 615 | return ( |
| 567 | <SettingsSwitchRow | |
| 568 | icon="arrow.triangle.branch" | |
| 569 | label={`Auto-settle merged threads · ${props.environmentLabel}`} | |
| 570 | value={settings?.sidebarAutoSettleOnMerge ?? true} | |
| 571 | onValueChange={(value) => { | |
| 572 | void updateSettings({ | |
| 573 | environmentId: props.environmentId, | |
| 574 | input: { patch: { sidebarAutoSettleOnMerge: value } }, | |
| 575 | }); | |
| 576 | }} | |
| 577 | /> | |
| 616 | <> | |
| 617 | <SettingsSwitchRow | |
| 618 | icon="arrow.triangle.branch" | |
| 619 | label="Auto-settle merged threads" | |
| 620 | value={referenceSettings.sidebarAutoSettleOnMerge} | |
| 621 | onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })} | |
| 622 | /> | |
| 623 | <SettingsSwitchRow | |
| 624 | icon="clock" | |
| 625 | label="Auto-settle inactive threads" | |
| 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 | 112 | TYPOGRAPHY_ADVANCED_STORAGE_KEY, |
| 113 | 113 | } from "../../appearanceFonts"; |
| 114 | 114 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 115 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | |
| 115 | 116 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; |
| 116 | 117 | import { |
| 117 | 118 | NumberField, |
| ... | ... | @@ -1928,6 +1929,7 @@ export function GeneralSettingsPanel() { |
| 1928 | 1929 | |
| 1929 | 1930 | return ( |
| 1930 | 1931 | <SettingsPageContainer> |
| 1932 | <SharedSettingsMismatchAlert /> | |
| 1931 | 1933 | <SettingsSection title="General"> |
| 1932 | 1934 | <SettingsRow |
| 1933 | 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 | 18 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 19 | 19 | |
| 20 | 20 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; |
| 21 | import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; | |
| 21 | 22 | import { cn } from "../../lib/utils"; |
| 22 | 23 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| 23 | 24 | import { useEnvironmentQuery } from "../../state/query"; |
| ... | ... | @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { |
| 548 | 549 | |
| 549 | 550 | return ( |
| 550 | 551 | <SettingsPageContainer> |
| 552 | <SharedSettingsMismatchAlert /> | |
| 551 | 553 | {isInitialScanPending ? ( |
| 552 | 554 | <> |
| 553 | 555 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> |
apps/web/src/hooks/useSettings.ts+109-5| ... | ... | @@ -25,6 +25,11 @@ import { |
| 25 | 25 | type UnifiedSettings, |
| 26 | 26 | } from "@t3tools/contracts/settings"; |
| 27 | 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 | 33 | import { ensureLocalApi } from "~/localApi"; |
| 29 | 34 | import { |
| 30 | 35 | getThemeDefinition, |
| ... | ... | @@ -35,7 +40,11 @@ import { |
| 35 | 40 | } from "~/themePalette"; |
| 36 | 41 | import * as Struct from "effect/Struct"; |
| 37 | 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 | 48 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 40 | 49 | import { useTheme } from "./useTheme"; |
| 41 | 50 | |
| ... | ... | @@ -307,28 +316,69 @@ export function usePrimarySettings<T = UnifiedSettings>( |
| 307 | 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 | 344 | * Returns an updater that routes each key to the correct backing store. |
| 312 | 345 | * |
| 313 | 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 | 352 | function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 317 | 353 | const persistServerSettings = useAtomCommand( |
| 318 | 354 | serverEnvironment.updateSettings, |
| 319 | 355 | "server settings update", |
| 320 | 356 | ); |
| 357 | const connectedEnvironmentIds = useConnectedEnvironmentIds(); | |
| 321 | 358 | const updateSettings = useCallback( |
| 322 | 359 | (patch: UnifiedSettingsPatch) => { |
| 323 | 360 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 324 | 361 | |
| 325 | 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 | 365 | void persistServerSettings({ |
| 328 | 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 | 383 | if (Object.keys(clientPatch).length > 0) { |
| 334 | 384 | persistClientSettings({ |
| ... | ... | @@ -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 | 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 | 450 | export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { |
| 347 | 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 | 88 | `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later |
| 89 | 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 | 95 | [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and |
| 93 | 96 | once per minute, including when no client is connected. It dispatches the guarded internal |
| 94 | 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 | 10 | Pinned threads still move to **Settled** when they become inactive. They also move when their pull |
| 11 | 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, | |
| 14 | desktop, or mobile client is connected. By default, it settles threads after three days without | |
| 13 | Each server stores its own copy of the automatic settlement settings and checks them even when no | |
| 14 | web, desktop, or mobile client is connected. By default, it settles threads after three days without | |
| 15 | 15 | activity and when their pull request merges. An eligible idle thread also settles when its pull |
| 16 | 16 | request closes. An open pull request blocks inactivity settlement. Active work, pending input, and |
| 17 | 17 | live background work keep the thread active. T3 Code settles from a closed or merged pull request |
| 18 | 18 | only when its timestamp is not older than the user's latest activity. If that timestamp is not |
| 19 | 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 | |
| 21 | settlement and does not reopen a settled thread. Settings saved by older clients on one device no | |
| 22 | longer control this behavior. | |
| 20 | ||
| 21 | Change these rules in **Settings > General**. The change is written to every environment you are | |
| 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 | 30 | When you un-settle a thread, it returns to the top of the active list so you can find it right |
| 25 | 31 | away. Its timestamps do not change. Other threads keep their positions. |
packages/client-runtime/package.json+4| ... | ... | @@ -147,6 +147,10 @@ |
| 147 | 147 | "types": "./src/state/session.ts", |
| 148 | 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 | 154 | "./state/shell": { |
| 151 | 155 | "types": "./src/state/shell.ts", |
| 152 | 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 | } |