| author | |
| committer | |
| log | 0fbb94248581aaefda36e4f8a40ec2c6455c779a |
| tree | e6031f5546e928c3879ee9203d403da1d8ad42a6 |
| parent | d897641d738c67dd7c12cbb3a273b43fe17a5eb1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>32 files changed, 1222 insertions(+), 84 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+7| ... | ... | @@ -22,6 +22,7 @@ import IconBox from "@tabler/icons-react-native/IconBox"; |
| 22 | 22 | import IconCamera from "@tabler/icons-react-native/IconCamera"; |
| 23 | 23 | import IconChartBar from "@tabler/icons-react-native/IconChartBar"; |
| 24 | 24 | import IconCheck from "@tabler/icons-react-native/IconCheck"; |
| 25 | import IconCloud from "@tabler/icons-react-native/IconCloud"; | |
| 25 | 26 | import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; |
| 26 | 27 | import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; |
| 27 | 28 | import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; |
| ... | ... | @@ -32,6 +33,7 @@ import IconClock from "@tabler/icons-react-native/IconClock"; |
| 32 | 33 | import IconCode from "@tabler/icons-react-native/IconCode"; |
| 33 | 34 | import IconCopy from "@tabler/icons-react-native/IconCopy"; |
| 34 | 35 | import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; |
| 36 | import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; | |
| 35 | 37 | import IconDots from "@tabler/icons-react-native/IconDots"; |
| 36 | 38 | import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; |
| 37 | 39 | import IconEdit from "@tabler/icons-react-native/IconEdit"; |
| ... | ... | @@ -110,6 +112,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 110 | 112 | checkmark: IconCheck, |
| 111 | 113 | "checkmark.circle": IconCircleCheck, |
| 112 | 114 | clock: IconClock, |
| 115 | cloud: IconCloud, | |
| 113 | 116 | cube: IconBox, |
| 114 | 117 | "chevron.down": IconChevronDown, |
| 115 | 118 | "chevron.left": IconChevronLeft, |
| ... | ... | @@ -129,9 +132,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 129 | 132 | "folder.fill": IconFolder, |
| 130 | 133 | gearshape: IconSettings, |
| 131 | 134 | "info.circle": IconInfoCircle, |
| 135 | laptopcomputer: IconDeviceLaptop, | |
| 132 | 136 | link: IconLink, |
| 133 | 137 | "line.3.horizontal.decrease.circle": IconFilter, |
| 134 | 138 | "line.3.horizontal.decrease.circle.fill": IconFilterFilled, |
| 139 | // Tabler has no Apple desktops; the closest silhouettes stand in on Android. | |
| 140 | macmini: IconServer, | |
| 141 | macstudio: IconDeviceDesktop, | |
| 135 | 142 | magnifyingglass: IconSearch, |
| 136 | 143 | paintbrush: IconPalette, |
| 137 | 144 | "person.crop.circle": IconUserCircle, |
apps/mobile/src/components/EnvironmentMachineSymbol.tsx created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 2 | import type { SFSymbol } from "expo-symbols"; | |
| 3 | ||
| 4 | import { SymbolView } from "./AppSymbol"; | |
| 5 | ||
| 6 | const SYMBOL_BY_KIND: Record<EnvironmentMachineKind, SFSymbol> = { | |
| 7 | server: "server.rack", | |
| 8 | cloud: "cloud", | |
| 9 | desktop: "desktopcomputer", | |
| 10 | laptop: "laptopcomputer", | |
| 11 | "mac-mini": "macmini", | |
| 12 | "mac-studio": "macstudio", | |
| 13 | }; | |
| 14 | ||
| 15 | export const ENVIRONMENT_MACHINE_KIND_LABELS: Record<EnvironmentMachineKind, string> = { | |
| 16 | server: "Server", | |
| 17 | cloud: "Cloud VM", | |
| 18 | desktop: "Desktop", | |
| 19 | laptop: "Laptop", | |
| 20 | "mac-mini": "Mac mini", | |
| 21 | "mac-studio": "Mac Studio", | |
| 22 | }; | |
| 23 | ||
| 24 | /** The glyph an environment wears in lists; SF Symbols on iOS, Tabler on Android. */ | |
| 25 | export function EnvironmentMachineSymbol(props: { | |
| 26 | readonly kind: EnvironmentMachineKind; | |
| 27 | readonly size: number; | |
| 28 | readonly tintColorClassName: string; | |
| 29 | }) { | |
| 30 | return ( | |
| 31 | <SymbolView | |
| 32 | accessibilityLabel={ENVIRONMENT_MACHINE_KIND_LABELS[props.kind]} | |
| 33 | name={SYMBOL_BY_KIND[props.kind]} | |
| 34 | size={props.size} | |
| 35 | tintColorClassName={props.tintColorClassName} | |
| 36 | type="monochrome" | |
| 37 | /> | |
| 38 | ); | |
| 39 | } |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+21-1| ... | ... | @@ -4,7 +4,12 @@ import { |
| 4 | 4 | connectionStatusText, |
| 5 | 5 | type EnvironmentConnectionPhase, |
| 6 | 6 | } from "@t3tools/client-runtime/connection"; |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | type EnvironmentId, | |
| 9 | type EnvironmentMachineKind, | |
| 10 | resolveEnvironmentMachineKind, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { useAtomValue } from "@effect/atom-react"; | |
| 8 | 13 | import { useCallback, useState } from "react"; |
| 9 | 14 | import { |
| 10 | 15 | ActivityIndicator, |
| ... | ... | @@ -15,10 +20,12 @@ import { |
| 15 | 20 | } from "react-native"; |
| 16 | 21 | |
| 17 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | |
| 18 | 24 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 19 | 25 | import { cn } from "../../lib/cn"; |
| 20 | 26 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 21 | 27 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 28 | import { serverEnvironment } from "../../state/server"; | |
| 22 | 29 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; |
| 23 | 30 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 24 | 31 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| ... | ... | @@ -205,6 +212,9 @@ function ConnectedCloudEnvironmentRow(props: { |
| 205 | 212 | readonly onDisconnect: () => void; |
| 206 | 213 | readonly onToggleError: () => void; |
| 207 | 214 | }) { |
| 215 | const serverConfig = useAtomValue( | |
| 216 | serverEnvironment.configValueAtom(props.environment.environmentId), | |
| 217 | ); | |
| 208 | 218 | return ( |
| 209 | 219 | <CloudEnvironmentRowShell |
| 210 | 220 | borderTop={props.borderTop} |
| ... | ... | @@ -213,6 +223,7 @@ function ConnectedCloudEnvironmentRow(props: { |
| 213 | 223 | connectionState={props.environment.connectionState} |
| 214 | 224 | errorExpanded={props.errorExpanded} |
| 215 | 225 | label={props.environment.environmentLabel} |
| 226 | machine={resolveEnvironmentMachineKind(serverConfig)} | |
| 216 | 227 | onValueChange={(enabled) => { |
| 217 | 228 | if (enabled) { |
| 218 | 229 | props.onConnect(); |
| ... | ... | @@ -268,6 +279,8 @@ function CloudEnvironmentRowShell(props: { |
| 268 | 279 | readonly disabled?: boolean; |
| 269 | 280 | readonly errorExpanded: boolean; |
| 270 | 281 | readonly label: string; |
| 282 | /** Absent for environments the relay lists but this device has not connected to. */ | |
| 283 | readonly machine?: EnvironmentMachineKind; | |
| 271 | 284 | readonly onToggleError: () => void; |
| 272 | 285 | readonly onValueChange: (enabled: boolean) => void; |
| 273 | 286 | readonly statusText?: string; |
| ... | ... | @@ -323,6 +336,13 @@ function CloudEnvironmentRowShell(props: { |
| 323 | 336 | <View className="min-w-0 flex-1 gap-0.5"> |
| 324 | 337 | <View className="min-w-0 flex-row items-center gap-2"> |
| 325 | 338 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| 339 | {props.machine ? ( | |
| 340 | <EnvironmentMachineSymbol | |
| 341 | kind={props.machine} | |
| 342 | size={14} | |
| 343 | tintColorClassName="accent-foreground-muted" | |
| 344 | /> | |
| 345 | ) : null} | |
| 326 | 346 | <Text |
| 327 | 347 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" |
| 328 | 348 | numberOfLines={1} |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+20-4| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | import { SymbolView } from "../../components/AppSymbol"; |
| 2 | 2 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 3 | 3 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 4 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 5 | import { useAtomValue } from "@effect/atom-react"; | |
| 5 | 6 | import * as Cause from "effect/Cause"; |
| 6 | 7 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 7 | 8 | import { useCallback, useState } from "react"; |
| ... | ... | @@ -9,9 +10,11 @@ import { Alert, Pressable, View } from "react-native"; |
| 9 | 10 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 10 | 11 | |
| 11 | 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | |
| 12 | 14 | import { cn } from "../../lib/cn"; |
| 13 | 15 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 14 | 16 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 17 | import { serverEnvironment } from "../../state/server"; | |
| 15 | 18 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 16 | 19 | |
| 17 | 20 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { |
| ... | ... | @@ -35,6 +38,9 @@ export function ConnectionEnvironmentRow(props: { |
| 35 | 38 | }) { |
| 36 | 39 | const [label, setLabel] = useState(props.environment.environmentLabel); |
| 37 | 40 | const [url, setUrl] = useState(props.environment.displayUrl); |
| 41 | const serverConfig = useAtomValue( | |
| 42 | serverEnvironment.configValueAtom(props.environment.environmentId), | |
| 43 | ); | |
| 38 | 44 | const statusLabel = connectionStatusLabel(props.environment); |
| 39 | 45 | const statusTraceId = props.environment.connectionErrorTraceId; |
| 40 | 46 | const hasConnectionFailure = props.environment.connectionError !== null; |
| ... | ... | @@ -70,9 +76,19 @@ export function ConnectionEnvironmentRow(props: { |
| 70 | 76 | /> |
| 71 | 77 | |
| 72 | 78 | <View className="flex-1 gap-0.5"> |
| 73 | <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}> | |
| 74 | {props.environment.environmentLabel} | |
| 75 | </Text> | |
| 79 | <View className="flex-row items-center gap-1.5"> | |
| 80 | <EnvironmentMachineSymbol | |
| 81 | kind={resolveEnvironmentMachineKind(serverConfig)} | |
| 82 | size={14} | |
| 83 | tintColorClassName="accent-foreground-muted" | |
| 84 | /> | |
| 85 | <Text | |
| 86 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" | |
| 87 | numberOfLines={1} | |
| 88 | > | |
| 89 | {props.environment.environmentLabel} | |
| 90 | </Text> | |
| 91 | </View> | |
| 76 | 92 | <Text className="text-xs text-foreground-muted" numberOfLines={1}> |
| 77 | 93 | {props.environment.displayUrl} |
| 78 | 94 | </Text> |
apps/mobile/src/features/home/HomeScreen.tsx+23-4| ... | ... | @@ -12,10 +12,11 @@ import { |
| 12 | 12 | type EnvironmentThreadSearchMatch, |
| 13 | 13 | } from "@t3tools/client-runtime/state/thread-search"; |
| 14 | 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 15 | import type { | |
| 16 | EnvironmentId, | |
| 17 | SidebarProjectGroupingMode, | |
| 18 | SidebarThreadSortOrder, | |
| 15 | import { | |
| 16 | type EnvironmentId, | |
| 17 | resolveEnvironmentMachineKind, | |
| 18 | type SidebarProjectGroupingMode, | |
| 19 | type SidebarThreadSortOrder, | |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 21 | 22 | import { AsyncResult } from "effect/unstable/reactivity"; |
| ... | ... | @@ -612,6 +613,16 @@ export function HomeScreen(props: HomeScreenProps) { |
| 612 | 613 | } |
| 613 | 614 | return supported; |
| 614 | 615 | }, [serverConfigs]); |
| 616 | const machineByEnvironmentId = useMemo( | |
| 617 | () => | |
| 618 | new Map( | |
| 619 | [...serverConfigs].map( | |
| 620 | ([environmentId, config]) => | |
| 621 | [environmentId, resolveEnvironmentMachineKind(config)] as const, | |
| 622 | ), | |
| 623 | ), | |
| 624 | [serverConfigs], | |
| 625 | ); | |
| 615 | 626 | // Canonical arranged pinned order (reorder-capable threads only) for the |
| 616 | 627 | // Move up/down position flags. Computed from all shells, not the rendered |
| 617 | 628 | // list, so search/scope filtering never disables or misdirects a move. |
| ... | ... | @@ -740,6 +751,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 740 | 751 | ?.environmentLabel ?? null) |
| 741 | 752 | : null |
| 742 | 753 | } |
| 754 | environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)} | |
| 743 | 755 | showPendingDivider={item.showPendingDivider} |
| 744 | 756 | showTrailingDivider={showTrailingDivider} |
| 745 | 757 | onSelectPendingTask={props.onSelectPendingTask} |
| ... | ... | @@ -797,6 +809,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 797 | 809 | ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) |
| 798 | 810 | : null |
| 799 | 811 | } |
| 812 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} | |
| 800 | 813 | searchMatch={threadSearchMatchByKey.get( |
| 801 | 814 | threadSearchMatchKey({ |
| 802 | 815 | environmentId: thread.environmentId, |
| ... | ... | @@ -847,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 847 | 860 | handleSwipeableWillOpen, |
| 848 | 861 | handleUnsettleThread, |
| 849 | 862 | pinningEnvironmentIds, |
| 863 | machineByEnvironmentId, | |
| 850 | 864 | pinReorderEnvironmentIds, |
| 851 | 865 | projectByKey, |
| 852 | 866 | projectCwdByKey, |
| ... | ... | @@ -938,6 +952,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 938 | 952 | props.savedConnectionsById[item.pendingTask.message.environmentId] |
| 939 | 953 | ?.environmentLabel ?? null |
| 940 | 954 | } |
| 955 | environmentMachine={machineByEnvironmentId.get( | |
| 956 | item.pendingTask.message.environmentId, | |
| 957 | )} | |
| 941 | 958 | isLast={item.isLast} |
| 942 | 959 | onSelectPendingTask={props.onSelectPendingTask} |
| 943 | 960 | onDeletePendingTask={props.onDeletePendingTask} |
| ... | ... | @@ -952,6 +969,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 952 | 969 | environmentLabel={ |
| 953 | 970 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 954 | 971 | } |
| 972 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} | |
| 955 | 973 | projectCwd={ |
| 956 | 974 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? |
| 957 | 975 | null |
| ... | ... | @@ -990,6 +1008,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 990 | 1008 | handleSwipeableClose, |
| 991 | 1009 | handleSwipeableWillOpen, |
| 992 | 1010 | handleRegenerateThreadTitle, |
| 1011 | machineByEnvironmentId, | |
| 993 | 1012 | projectCwdByKey, |
| 994 | 1013 | props.onArchiveThread, |
| 995 | 1014 | props.onDeletePendingTask, |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+20-1| ... | ... | @@ -9,7 +9,7 @@ import { |
| 9 | 9 | import { LegendList } from "@legendapp/list/react-native"; |
| 10 | 10 | import type { MenuAction } from "@react-native-menu/menu"; |
| 11 | 11 | import { useAtomValue } from "@effect/atom-react"; |
| 12 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 12 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 13 | 13 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; |
| 14 | 14 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 15 | 15 | import type { LayoutChangeEvent } from "react-native"; |
| ... | ... | @@ -441,6 +441,16 @@ function ThreadNavigationSidebarPane( |
| 441 | 441 | } |
| 442 | 442 | return supported; |
| 443 | 443 | }, [serverConfigs]); |
| 444 | const machineByEnvironmentId = useMemo( | |
| 445 | () => | |
| 446 | new Map( | |
| 447 | [...serverConfigs].map( | |
| 448 | ([environmentId, config]) => | |
| 449 | [environmentId, resolveEnvironmentMachineKind(config)] as const, | |
| 450 | ), | |
| 451 | ), | |
| 452 | [serverConfigs], | |
| 453 | ); | |
| 444 | 454 | // Canonical arranged pinned order for Move up/down flags — computed from |
| 445 | 455 | // all shells so search/scope filtering never disables a valid move. |
| 446 | 456 | const arrangedPinnedKeys = useMemo(() => { |
| ... | ... | @@ -819,6 +829,9 @@ function ThreadNavigationSidebarPane( |
| 819 | 829 | ?.environmentLabel ?? null) |
| 820 | 830 | : null |
| 821 | 831 | } |
| 832 | environmentMachine={machineByEnvironmentId.get( | |
| 833 | item.pendingTask.message.environmentId, | |
| 834 | )} | |
| 822 | 835 | pane="sidebar" |
| 823 | 836 | showPendingDivider={item.showPendingDivider} |
| 824 | 837 | onSelectPendingTask={openPendingTask} |
| ... | ... | @@ -853,6 +866,7 @@ function ThreadNavigationSidebarPane( |
| 853 | 866 | ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) |
| 854 | 867 | : null |
| 855 | 868 | } |
| 869 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} | |
| 856 | 870 | searchMatch={threadSearchMatchByKey.get( |
| 857 | 871 | threadSearchMatchKey({ |
| 858 | 872 | environmentId: thread.environmentId, |
| ... | ... | @@ -956,6 +970,9 @@ function ThreadNavigationSidebarPane( |
| 956 | 970 | savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? |
| 957 | 971 | null |
| 958 | 972 | } |
| 973 | environmentMachine={machineByEnvironmentId.get( | |
| 974 | item.pendingTask.message.environmentId, | |
| 975 | )} | |
| 959 | 976 | isLast={item.isLast} |
| 960 | 977 | onSelectPendingTask={openPendingTask} |
| 961 | 978 | onDeletePendingTask={confirmDeletePendingTask} |
| ... | ... | @@ -970,6 +987,7 @@ function ThreadNavigationSidebarPane( |
| 970 | 987 | environmentLabel={ |
| 971 | 988 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 972 | 989 | } |
| 990 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} | |
| 973 | 991 | projectCwd={ |
| 974 | 992 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? |
| 975 | 993 | null |
| ... | ... | @@ -1017,6 +1035,7 @@ function ThreadNavigationSidebarPane( |
| 1017 | 1035 | handleSelectThread, |
| 1018 | 1036 | handleSwipeableClose, |
| 1019 | 1037 | handleSwipeableWillOpen, |
| 1038 | machineByEnvironmentId, | |
| 1020 | 1039 | movePinnedThread, |
| 1021 | 1040 | openPendingTask, |
| 1022 | 1041 | pinReorderEnvironmentIds, |
apps/mobile/src/features/threads/thread-list-items.tsx+24| ... | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | 4 | EnvironmentThreadShell, |
| 5 | 5 | } from "@t3tools/client-runtime/state/shell"; |
| 6 | 6 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 7 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 7 | 8 | import type { MenuAction } from "@react-native-menu/menu"; |
| 8 | 9 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 10 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; |
| ... | ... | @@ -14,6 +15,7 @@ import Svg, { Circle, Path } from "react-native-svg"; |
| 14 | 15 | |
| 15 | 16 | import { AppText as Text } from "../../components/AppText"; |
| 16 | 17 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 18 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | |
| 17 | 19 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 18 | 20 | import { cn } from "../../lib/cn"; |
| 19 | 21 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; |
| ... | ... | @@ -268,6 +270,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { |
| 268 | 270 | readonly variant: ThreadListVariant; |
| 269 | 271 | readonly pendingTask: PendingNewTask; |
| 270 | 272 | readonly environmentLabel: string | null; |
| 273 | readonly environmentMachine?: EnvironmentMachineKind; | |
| 271 | 274 | readonly isLast: boolean; |
| 272 | 275 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 273 | 276 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| ... | ... | @@ -305,6 +308,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { |
| 305 | 308 | tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"} |
| 306 | 309 | type="monochrome" |
| 307 | 310 | /> |
| 311 | {props.environmentLabel && props.environmentMachine ? ( | |
| 312 | <EnvironmentMachineSymbol | |
| 313 | kind={props.environmentMachine} | |
| 314 | size={compact ? 12 : 10} | |
| 315 | tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"} | |
| 316 | /> | |
| 317 | ) : null} | |
| 308 | 318 | <Text |
| 309 | 319 | className={ |
| 310 | 320 | compact |
| ... | ... | @@ -416,6 +426,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 416 | 426 | readonly variant: ThreadListVariant; |
| 417 | 427 | readonly thread: EnvironmentThreadShell; |
| 418 | 428 | readonly environmentLabel: string | null; |
| 429 | readonly environmentMachine?: EnvironmentMachineKind; | |
| 419 | 430 | readonly projectCwd: string | null; |
| 420 | 431 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 421 | 432 | readonly searchQuery?: string; |
| ... | ... | @@ -524,6 +535,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 524 | 535 | <View className="mt-px flex-row items-center gap-1.5"> |
| 525 | 536 | {subtitleParts.length > 0 ? ( |
| 526 | 537 | <> |
| 538 | {props.environmentLabel && props.environmentMachine ? ( | |
| 539 | <EnvironmentMachineSymbol | |
| 540 | kind={props.environmentMachine} | |
| 541 | size={compact ? 12 : 10} | |
| 542 | tintColorClassName={ | |
| 543 | compact | |
| 544 | ? "accent-icon-subtle" | |
| 545 | : selected | |
| 546 | ? "accent-user-bubble-foreground-muted" | |
| 547 | : "accent-foreground-muted" | |
| 548 | } | |
| 549 | /> | |
| 550 | ) : null} | |
| 527 | 551 | <Text |
| 528 | 552 | className={cn( |
| 529 | 553 | "shrink", |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+35-10| ... | ... | @@ -3,6 +3,7 @@ import type { |
| 3 | 3 | EnvironmentThreadShell, |
| 4 | 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 6 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 6 | 7 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; |
| 7 | 8 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; |
| 8 | 9 | import type { MenuAction } from "@react-native-menu/menu"; |
| ... | ... | @@ -13,6 +14,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw |
| 13 | 14 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | 15 | import { AppText as Text } from "../../components/AppText"; |
| 15 | 16 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 17 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | |
| 16 | 18 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 17 | 19 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 18 | 20 | import { cn } from "../../lib/cn"; |
| ... | ... | @@ -201,6 +203,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props |
| 201 | 203 | readonly project: EnvironmentProject | null; |
| 202 | 204 | readonly projectTitle?: string; |
| 203 | 205 | readonly environmentLabel: string | null; |
| 206 | /** Drawn beside the label; ignored while the label is null. */ | |
| 207 | readonly environmentMachine?: EnvironmentMachineKind; | |
| 204 | 208 | readonly pane?: "screen" | "sidebar"; |
| 205 | 209 | /** Draws the "Pending" divider above the first queued row. */ |
| 206 | 210 | readonly showPendingDivider: boolean; |
| ... | ... | @@ -249,17 +253,26 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props |
| 249 | 253 | {pendingTask.title} |
| 250 | 254 | </Text> |
| 251 | 255 | {branch || props.environmentLabel ? ( |
| 252 | <Text className="mt-1 text-xs text-foreground-muted" numberOfLines={1}> | |
| 253 | {branch ? ( | |
| 254 | <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}> | |
| 255 | {branch} | |
| 256 | </Text> | |
| 257 | ) : null} | |
| 258 | {branch && props.environmentLabel ? " · " : null} | |
| 259 | {props.environmentLabel ? ( | |
| 260 | <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text> | |
| 256 | <View className="mt-1 flex-row items-center gap-1"> | |
| 257 | <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}> | |
| 258 | {branch ? ( | |
| 259 | <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}> | |
| 260 | {branch} | |
| 261 | </Text> | |
| 262 | ) : null} | |
| 263 | {branch && props.environmentLabel ? " · " : null} | |
| 264 | {props.environmentLabel ? ( | |
| 265 | <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text> | |
| 266 | ) : null} | |
| 267 | </Text> | |
| 268 | {props.environmentLabel && props.environmentMachine ? ( | |
| 269 | <EnvironmentMachineSymbol | |
| 270 | kind={props.environmentMachine} | |
| 271 | size={11} | |
| 272 | tintColorClassName="accent-foreground-tertiary" | |
| 273 | /> | |
| 261 | 274 | ) : null} |
| 262 | </Text> | |
| 275 | </View> | |
| 263 | 276 | ) : null} |
| 264 | 277 | </> |
| 265 | 278 | ); |
| ... | ... | @@ -327,6 +340,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 327 | 340 | the web sidebar's remote-environment cloud icon, but as text since |
| 328 | 341 | phones have no hover tooltips. */ |
| 329 | 342 | readonly environmentLabel: string | null; |
| 343 | /** Drawn after the label so the machine reads at a glance; ignored while | |
| 344 | the label is null. */ | |
| 345 | readonly environmentMachine?: EnvironmentMachineKind; | |
| 330 | 346 | /** Hosting surface. "screen" (default) renders the compact Home idiom: |
| 331 | 347 | flat edge-to-edge rows on the screen background with inset hairlines. |
| 332 | 348 | "sidebar" renders the iPad split-view idiom: rounded rows blending |
| ... | ... | @@ -767,6 +783,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 767 | 783 | ) : ( |
| 768 | 784 | <View className="flex-1" /> |
| 769 | 785 | )} |
| 786 | {status !== "failed" && props.environmentLabel && props.environmentMachine ? ( | |
| 787 | <EnvironmentMachineSymbol | |
| 788 | kind={props.environmentMachine} | |
| 789 | size={11} | |
| 790 | tintColorClassName={ | |
| 791 | selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-tertiary" | |
| 792 | } | |
| 793 | /> | |
| 794 | ) : null} | |
| 770 | 795 | {pr ? ( |
| 771 | 796 | <Text |
| 772 | 797 | accessibilityLabel={pr.accessibilityLabel} |
apps/server/src/environment/ServerEnvironment.ts+4| ... | ... | @@ -20,6 +20,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; |
| 20 | 20 | import * as ServerConfig from "../config.ts"; |
| 21 | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 22 | 22 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 23 | import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; | |
| 23 | 24 | |
| 24 | 25 | export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()( |
| 25 | 26 | "ServerEnvironmentIdPersistenceError", |
| ... | ... | @@ -188,6 +189,7 @@ export const make = Effect.gen(function* () { |
| 188 | 189 | const environmentId = yield* identity.getEnvironmentId; |
| 189 | 190 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); |
| 190 | 191 | const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); |
| 192 | const machine = yield* detectServerEnvironmentMachineKind(); | |
| 191 | 193 | const launcher = yield* resolveServiceLauncherMode(); |
| 192 | 194 | const serverSelfUpdate = resolveServerSelfUpdateCapability({ |
| 193 | 195 | desktopManaged: serverConfig.mode === "desktop", |
| ... | ... | @@ -206,6 +208,7 @@ export const make = Effect.gen(function* () { |
| 206 | 208 | platform: { |
| 207 | 209 | os: platformOs(hostPlatform), |
| 208 | 210 | arch: platformArch(hostArchitecture), |
| 211 | ...(machine === null ? {} : { machine }), | |
| 209 | 212 | }, |
| 210 | 213 | serverVersion: packageJson.version, |
| 211 | 214 | capabilities: { |
| ... | ... | @@ -222,6 +225,7 @@ export const make = Effect.gen(function* () { |
| 222 | 225 | threadPinReorder: true, |
| 223 | 226 | threadTitleRegeneration: true, |
| 224 | 227 | threadPullRequestLinking: true, |
| 228 | environmentIcon: true, | |
| 225 | 229 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 226 | 230 | ...(serverSelfUpdate === "boot-service" || desktopAppUpdate |
| 227 | 231 | ? { |
apps/server/src/environment/ServerEnvironmentMachine.test.ts created+225| ... | ... | @@ -0,0 +1,225 @@ |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as PlatformError from "effect/PlatformError"; | |
| 6 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | import { vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | import * as ProcessRunner from "../processRunner.ts"; | |
| 11 | import { | |
| 12 | detectServerEnvironmentMachineKind, | |
| 13 | machineKindFromAppleProductName, | |
| 14 | machineKindFromDmi, | |
| 15 | } from "./ServerEnvironmentMachine.ts"; | |
| 16 | ||
| 17 | const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | |
| 18 | ||
| 19 | const ProcessRunnerTest = Layer.succeed( | |
| 20 | ProcessRunner.ProcessRunner, | |
| 21 | ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }), | |
| 22 | ); | |
| 23 | ||
| 24 | const processOutput = (stdout: string, code = 0) => | |
| 25 | Effect.succeed({ | |
| 26 | stdout, | |
| 27 | stderr: "", | |
| 28 | code: ChildProcessSpawner.ExitCode(code), | |
| 29 | timedOut: false, | |
| 30 | stdoutTruncated: false, | |
| 31 | stderrTruncated: false, | |
| 32 | stdoutInvalidUtf8: false, | |
| 33 | stderrInvalidUtf8: false, | |
| 34 | }); | |
| 35 | ||
| 36 | const dmiFileSystem = (files: Readonly<Record<string, string>>) => | |
| 37 | FileSystem.layerNoop({ | |
| 38 | readFileString: (path) => { | |
| 39 | const name = path.slice(path.lastIndexOf("/") + 1); | |
| 40 | return name in files | |
| 41 | ? Effect.succeed(files[name]!) | |
| 42 | : Effect.fail( | |
| 43 | PlatformError.systemError({ | |
| 44 | _tag: "NotFound", | |
| 45 | module: "FileSystem", | |
| 46 | method: "readFileString", | |
| 47 | pathOrDescriptor: path, | |
| 48 | cause: new Error("ENOENT"), | |
| 49 | }), | |
| 50 | ); | |
| 51 | }, | |
| 52 | }); | |
| 53 | ||
| 54 | const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => | |
| 55 | Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform)); | |
| 56 | ||
| 57 | afterEach(() => { | |
| 58 | runMock.mockReset(); | |
| 59 | }); | |
| 60 | ||
| 61 | describe("machineKindFromAppleProductName", () => { | |
| 62 | it("maps marketing names and model identifiers", () => { | |
| 63 | expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini"); | |
| 64 | expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini"); | |
| 65 | expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio"); | |
| 66 | expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop"); | |
| 67 | expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop"); | |
| 68 | expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop"); | |
| 69 | expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop"); | |
| 70 | }); | |
| 71 | ||
| 72 | it("returns null for Apple silicon model identifiers, which carry no product family", () => { | |
| 73 | expect(machineKindFromAppleProductName("Mac16,10")).toBeNull(); | |
| 74 | }); | |
| 75 | }); | |
| 76 | ||
| 77 | describe("machineKindFromDmi", () => { | |
| 78 | it("prefers virtualization markers over chassis type", () => { | |
| 79 | expect( | |
| 80 | machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }), | |
| 81 | ).toBe("cloud"); | |
| 82 | expect( | |
| 83 | machineKindFromDmi({ | |
| 84 | chassisType: "3", | |
| 85 | sysVendor: "Microsoft Corporation", | |
| 86 | productName: "Virtual Machine", | |
| 87 | }), | |
| 88 | ).toBe("cloud"); | |
| 89 | expect( | |
| 90 | machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }), | |
| 91 | ).toBe("cloud"); | |
| 92 | }); | |
| 93 | ||
| 94 | it("does not treat Microsoft hardware as a VM", () => { | |
| 95 | expect( | |
| 96 | machineKindFromDmi({ | |
| 97 | chassisType: "9", | |
| 98 | sysVendor: "Microsoft Corporation", | |
| 99 | productName: "Surface Laptop 5", | |
| 100 | }), | |
| 101 | ).toBe("laptop"); | |
| 102 | }); | |
| 103 | ||
| 104 | it("maps SMBIOS chassis codes", () => { | |
| 105 | expect( | |
| 106 | machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }), | |
| 107 | ).toBe("desktop"); | |
| 108 | expect( | |
| 109 | machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }), | |
| 110 | ).toBe("laptop"); | |
| 111 | expect( | |
| 112 | machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }), | |
| 113 | ).toBe("server"); | |
| 114 | expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull(); | |
| 115 | expect( | |
| 116 | machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }), | |
| 117 | ).toBeNull(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("recognizes Apple hardware running Linux", () => { | |
| 121 | expect( | |
| 122 | machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }), | |
| 123 | ).toBe("mac-studio"); | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("detectServerEnvironmentMachineKind", () => { | |
| 128 | it.effect("reads the IOKit product name on macOS", () => | |
| 129 | Effect.gen(function* () { | |
| 130 | runMock.mockReturnValueOnce( | |
| 131 | processOutput( | |
| 132 | '+-o product <class IOPlatformDevice>\n {\n "product-name" = <"Mac mini (2024)">\n }\n', | |
| 133 | ), | |
| 134 | ); | |
| 135 | ||
| 136 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 137 | Effect.provide(withPlatform("darwin")), | |
| 138 | ); | |
| 139 | ||
| 140 | expect(result).toBe("mac-mini"); | |
| 141 | expect(runMock).toHaveBeenCalledTimes(1); | |
| 142 | expect(runMock).toHaveBeenCalledWith( | |
| 143 | expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }), | |
| 144 | ); | |
| 145 | }), | |
| 146 | ); | |
| 147 | ||
| 148 | it.effect("falls back to hw.model when IOKit has no product node", () => | |
| 149 | Effect.gen(function* () { | |
| 150 | runMock.mockReturnValueOnce(processOutput("", 1)); | |
| 151 | runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n")); | |
| 152 | ||
| 153 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 154 | Effect.provide(withPlatform("darwin")), | |
| 155 | ); | |
| 156 | ||
| 157 | expect(result).toBe("laptop"); | |
| 158 | expect(runMock).toHaveBeenLastCalledWith( | |
| 159 | expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }), | |
| 160 | ); | |
| 161 | }), | |
| 162 | ); | |
| 163 | ||
| 164 | it.effect("returns null when both macOS probes fail", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | runMock.mockImplementation((input) => | |
| 167 | Effect.fail( | |
| 168 | new ProcessRunner.ProcessSpawnError({ | |
| 169 | command: input.command, | |
| 170 | argumentCount: input.args.length, | |
| 171 | cause: new Error("ENOENT"), | |
| 172 | }), | |
| 173 | ), | |
| 174 | ); | |
| 175 | ||
| 176 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 177 | Effect.provide(withPlatform("darwin")), | |
| 178 | ); | |
| 179 | ||
| 180 | expect(result).toBeNull(); | |
| 181 | expect(runMock).toHaveBeenCalledTimes(2); | |
| 182 | }), | |
| 183 | ); | |
| 184 | ||
| 185 | it.effect("reads DMI on Linux", () => | |
| 186 | Effect.gen(function* () { | |
| 187 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 188 | Effect.provide( | |
| 189 | withPlatform( | |
| 190 | "linux", | |
| 191 | dmiFileSystem({ | |
| 192 | chassis_type: "3\n", | |
| 193 | sys_vendor: "GMKtec\n", | |
| 194 | product_name: "NucBox K8 Plus\n", | |
| 195 | }), | |
| 196 | ), | |
| 197 | ), | |
| 198 | ); | |
| 199 | ||
| 200 | expect(result).toBe("desktop"); | |
| 201 | expect(runMock).not.toHaveBeenCalled(); | |
| 202 | }), | |
| 203 | ); | |
| 204 | ||
| 205 | it.effect("returns null on Linux without DMI (containers, ARM boards)", () => | |
| 206 | Effect.gen(function* () { | |
| 207 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 208 | Effect.provide(withPlatform("linux", dmiFileSystem({}))), | |
| 209 | ); | |
| 210 | ||
| 211 | expect(result).toBeNull(); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | it.effect("skips detection on other platforms", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | const result = yield* detectServerEnvironmentMachineKind().pipe( | |
| 218 | Effect.provide(withPlatform("win32")), | |
| 219 | ); | |
| 220 | ||
| 221 | expect(result).toBeNull(); | |
| 222 | expect(runMock).not.toHaveBeenCalled(); | |
| 223 | }), | |
| 224 | ); | |
| 225 | }); |
apps/server/src/environment/ServerEnvironmentMachine.ts created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | ||
| 6 | import * as ProcessRunner from "../processRunner.ts"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Best-effort hardware detection for the environment icon. Every probe is | |
| 10 | * allowed to fail: a null result means "no signal", and the client draws a | |
| 11 | * generic server until the user picks something in Settings → Connections. | |
| 12 | */ | |
| 13 | ||
| 14 | const DMI_ROOT = "/sys/class/dmi/id"; | |
| 15 | ||
| 16 | // SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape | |
| 17 | // rather than a machine (docking stations, blades enclosures, IoT gateways) | |
| 18 | // fall through to null on purpose. | |
| 19 | const DMI_CHASSIS_KINDS: Readonly<Record<string, EnvironmentMachineKind>> = { | |
| 20 | "3": "desktop", // Desktop | |
| 21 | "4": "desktop", // Low Profile Desktop | |
| 22 | "5": "desktop", // Pizza Box | |
| 23 | "6": "desktop", // Mini Tower | |
| 24 | "7": "desktop", // Tower | |
| 25 | "8": "laptop", // Portable | |
| 26 | "9": "laptop", // Laptop | |
| 27 | "10": "laptop", // Notebook | |
| 28 | "13": "desktop", // All in One | |
| 29 | "14": "laptop", // Sub Notebook | |
| 30 | "15": "desktop", // Space-saving | |
| 31 | "16": "desktop", // Lunch Box | |
| 32 | "17": "server", // Main Server Chassis | |
| 33 | "18": "server", // Expansion Chassis | |
| 34 | "19": "server", // SubChassis | |
| 35 | "20": "server", // Bus Expansion Chassis | |
| 36 | "21": "server", // Peripheral Chassis | |
| 37 | "22": "server", // RAID Chassis | |
| 38 | "23": "server", // Rack Mount Chassis | |
| 39 | "24": "server", // Sealed-case PC | |
| 40 | "28": "server", // Blade | |
| 41 | "31": "laptop", // Convertible | |
| 42 | "32": "laptop", // Detachable | |
| 43 | "35": "desktop", // Mini PC | |
| 44 | }; | |
| 45 | ||
| 46 | // Hypervisors and cloud providers write themselves into the DMI vendor or | |
| 47 | // product strings; any hit means the box is a VM, and a VM reads as "cloud" | |
| 48 | // regardless of the chassis type the hypervisor fakes. Hyper-V is matched on | |
| 49 | // its "Virtual Machine" product, not the "Microsoft Corporation" vendor that | |
| 50 | // physical Surface devices share. | |
| 51 | const VIRTUALIZATION_MARKERS = [ | |
| 52 | "qemu", | |
| 53 | "kvm", | |
| 54 | "bochs", | |
| 55 | "vmware", | |
| 56 | "virtualbox", | |
| 57 | "innotek", | |
| 58 | "xen", | |
| 59 | "parallels", | |
| 60 | "amazon ec2", | |
| 61 | "google compute engine", | |
| 62 | "digitalocean", | |
| 63 | "hetzner", | |
| 64 | "linode", | |
| 65 | "vultr", | |
| 66 | "scaleway", | |
| 67 | "openstack", | |
| 68 | "cloud", | |
| 69 | "virtual machine", | |
| 70 | ]; | |
| 71 | ||
| 72 | function normalize(value: string | null | undefined): string | null { | |
| 73 | const trimmed = value?.trim(); | |
| 74 | return trimmed && trimmed.length > 0 ? trimmed : null; | |
| 75 | } | |
| 76 | ||
| 77 | /** Marketing names and Intel-era model identifiers share these prefixes. */ | |
| 78 | export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { | |
| 79 | const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, ""); | |
| 80 | if (normalized.startsWith("macmini")) return "mac-mini"; | |
| 81 | if (normalized.startsWith("macstudio")) return "mac-studio"; | |
| 82 | if (normalized.startsWith("macbook")) return "laptop"; | |
| 83 | if (normalized.startsWith("imac") || normalized.startsWith("macpro")) return "desktop"; | |
| 84 | return null; | |
| 85 | } | |
| 86 | ||
| 87 | export function machineKindFromDmi(input: { | |
| 88 | readonly chassisType: string | null; | |
| 89 | readonly sysVendor: string | null; | |
| 90 | readonly productName: string | null; | |
| 91 | }): EnvironmentMachineKind | null { | |
| 92 | const productName = input.productName ?? ""; | |
| 93 | const vendorAndProduct = `${input.sysVendor ?? ""} ${productName}`.toLowerCase(); | |
| 94 | if (VIRTUALIZATION_MARKERS.some((marker) => vendorAndProduct.includes(marker))) { | |
| 95 | return "cloud"; | |
| 96 | } | |
| 97 | // Apple hardware booting Linux (Asahi) still reports the Apple product name. | |
| 98 | const appleKind = machineKindFromAppleProductName(productName); | |
| 99 | if (appleKind !== null) { | |
| 100 | return appleKind; | |
| 101 | } | |
| 102 | return input.chassisType === null ? null : (DMI_CHASSIS_KINDS[input.chassisType] ?? null); | |
| 103 | } | |
| 104 | ||
| 105 | const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) { | |
| 106 | const fileSystem = yield* FileSystem.FileSystem; | |
| 107 | return yield* fileSystem.readFileString(path).pipe( | |
| 108 | Effect.map(normalize), | |
| 109 | Effect.catch(() => Effect.succeed(null)), | |
| 110 | ); | |
| 111 | }); | |
| 112 | ||
| 113 | const runProbe = Effect.fn("runMachineProbe")(function* (input: { | |
| 114 | readonly command: string; | |
| 115 | readonly args: ReadonlyArray<string>; | |
| 116 | }) { | |
| 117 | const processRunner = yield* ProcessRunner.ProcessRunner; | |
| 118 | return yield* processRunner | |
| 119 | .run({ | |
| 120 | command: input.command, | |
| 121 | args: input.args, | |
| 122 | timeout: "5 seconds", | |
| 123 | timeoutBehavior: "timedOutResult", | |
| 124 | }) | |
| 125 | .pipe( | |
| 126 | Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), | |
| 127 | Effect.catch(() => Effect.succeed(null)), | |
| 128 | ); | |
| 129 | }); | |
| 130 | ||
| 131 | // IOKit's `product` node carries the marketing name ("Mac mini (2024)") on | |
| 132 | // Apple silicon; Intel Macs lack it, so `hw.model` ("Macmini8,1") is the | |
| 133 | // fallback. Both are single-digit-millisecond calls. | |
| 134 | const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* () { | |
| 135 | const ioreg = yield* runProbe({ command: "ioreg", args: ["-rd1", "-n", "product"] }); | |
| 136 | const productName = ioreg?.match(/"product-name"\s*=\s*<"([^"]+)">/)?.[1] ?? null; | |
| 137 | const fromProductName = | |
| 138 | productName === null ? null : machineKindFromAppleProductName(productName); | |
| 139 | if (fromProductName !== null) { | |
| 140 | return fromProductName; | |
| 141 | } | |
| 142 | const model = yield* runProbe({ command: "sysctl", args: ["-n", "hw.model"] }); | |
| 143 | return model === null ? null : machineKindFromAppleProductName(model); | |
| 144 | }); | |
| 145 | ||
| 146 | const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { | |
| 147 | const [chassisType, sysVendor, productName] = yield* Effect.all([ | |
| 148 | readOptionalFile(`${DMI_ROOT}/chassis_type`), | |
| 149 | readOptionalFile(`${DMI_ROOT}/sys_vendor`), | |
| 150 | readOptionalFile(`${DMI_ROOT}/product_name`), | |
| 151 | ]); | |
| 152 | return machineKindFromDmi({ chassisType, sysVendor, productName }); | |
| 153 | }); | |
| 154 | ||
| 155 | export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")( | |
| 156 | function* () { | |
| 157 | const platform = yield* HostProcessPlatform; | |
| 158 | switch (platform) { | |
| 159 | case "darwin": | |
| 160 | return yield* detectDarwinMachineKind(); | |
| 161 | case "linux": | |
| 162 | return yield* detectLinuxMachineKind(); | |
| 163 | default: | |
| 164 | return null; | |
| 165 | } | |
| 166 | }, | |
| 167 | ); |
apps/web/src/components/BranchToolbar.logic.ts+2-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import { toSortableTimestamp } from "../lib/threadSort"; |
| 4 | 4 | export { |
| ... | ... | @@ -11,6 +11,7 @@ export interface EnvironmentOption { |
| 11 | 11 | projectId: ProjectId; |
| 12 | 12 | label: string; |
| 13 | 13 | isPrimary: boolean; |
| 14 | machine: EnvironmentMachineKind; | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | 17 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
apps/web/src/components/BranchToolbar.tsx+17-19| ... | ... | @@ -2,16 +2,15 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ |
| 2 | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import { |
| 4 | 4 | ChevronDownIcon, |
| 5 | CloudIcon, | |
| 6 | 5 | FolderGit2Icon, |
| 7 | 6 | FolderGitIcon, |
| 8 | 7 | FolderIcon, |
| 9 | 8 | HistoryIcon, |
| 10 | MonitorIcon, | |
| 11 | 9 | } from "lucide-react"; |
| 12 | 10 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 13 | 11 | |
| 14 | 12 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 13 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | |
| 15 | 14 | import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; |
| 16 | 15 | import { useIsMobile } from "../hooks/useMediaQuery"; |
| 17 | 16 | import { |
| ... | ... | @@ -105,12 +104,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 105 | 104 | ? resolveEnvModeLabel("worktree") |
| 106 | 105 | : resolveCurrentWorkspaceLabel(activeWorktreePath); |
| 107 | 106 | const isLocked = envLocked || envModeLocked; |
| 108 | const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; | |
| 109 | 107 | const icon = showEnvironmentIndicator ? ( |
| 110 | 108 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px |
| 111 | 109 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. |
| 112 | 110 | <span className="inline-flex shrink-0 items-center gap-0.5"> |
| 113 | <EnvironmentIcon className="size-3 shrink-0 mx-0!" /> | |
| 111 | <EnvironmentMachineIcon | |
| 112 | kind={activeEnvironment?.machine ?? "server"} | |
| 113 | className="size-3 shrink-0 mx-0!" | |
| 114 | /> | |
| 114 | 115 | <WorkspaceIcon className="size-3 shrink-0 mx-0!" /> |
| 115 | 116 | </span> |
| 116 | 117 | ) : ( |
| ... | ... | @@ -151,21 +152,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 151 | 152 | value={environmentId} |
| 152 | 153 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} |
| 153 | 154 | > |
| 154 | {availableEnvironments.map((env) => { | |
| 155 | const Icon = env.isPrimary ? MonitorIcon : CloudIcon; | |
| 156 | return ( | |
| 157 | <MenuRadioItem | |
| 158 | key={env.environmentId} | |
| 159 | disabled={envLocked} | |
| 160 | value={env.environmentId} | |
| 161 | > | |
| 162 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 163 | <Icon className="size-3" /> | |
| 164 | <span className="min-w-0 truncate">{env.label}</span> | |
| 165 | </span> | |
| 166 | </MenuRadioItem> | |
| 167 | ); | |
| 168 | })} | |
| 155 | {availableEnvironments.map((env) => ( | |
| 156 | <MenuRadioItem | |
| 157 | key={env.environmentId} | |
| 158 | disabled={envLocked} | |
| 159 | value={env.environmentId} | |
| 160 | > | |
| 161 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 162 | <EnvironmentMachineIcon kind={env.machine} className="size-3" /> | |
| 163 | <span className="min-w-0 truncate">{env.label}</span> | |
| 164 | </span> | |
| 165 | </MenuRadioItem> | |
| 166 | ))} | |
| 169 | 167 | </MenuRadioGroup> |
| 170 | 168 | </MenuGroup> |
| 171 | 169 | <MenuSeparator /> |
apps/web/src/components/ChatView.tsx+6-8| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 23 | 23 | ProviderInteractionMode, |
| 24 | 24 | ProviderDriverKind, |
| 25 | resolveEnvironmentMachineKind, | |
| 25 | 26 | RuntimeMode, |
| 26 | 27 | TerminalOpenInput, |
| 27 | 28 | } from "@t3tools/contracts"; |
| ... | ... | @@ -303,6 +304,7 @@ import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 303 | 304 | import { NoActiveThreadState } from "./NoActiveThreadState"; |
| 304 | 305 | import { WorkspacePageHeader } from "./WorkspacePageHeader"; |
| 305 | 306 | import { |
| 307 | type EnvironmentOption, | |
| 306 | 308 | resolveEffectiveEnvMode, |
| 307 | 309 | resolveLocalCheckoutBranchMismatch, |
| 308 | 310 | shouldShowComposerContextStrip, |
| ... | ... | @@ -2061,22 +2063,18 @@ function ChatViewContent(props: ChatViewProps) { |
| 2061 | 2063 | (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, |
| 2062 | 2064 | ); |
| 2063 | 2065 | const seen = new Set<string>(); |
| 2064 | const envs: Array<{ | |
| 2065 | environmentId: EnvironmentId; | |
| 2066 | projectId: ProjectId; | |
| 2067 | label: string; | |
| 2068 | isPrimary: boolean; | |
| 2069 | }> = []; | |
| 2066 | const envs: EnvironmentOption[] = []; | |
| 2070 | 2067 | for (const p of memberProjects) { |
| 2071 | 2068 | if (seen.has(p.environmentId)) continue; |
| 2072 | 2069 | seen.add(p.environmentId); |
| 2073 | 2070 | const isPrimary = p.environmentId === primaryEnvironmentId; |
| 2074 | const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; | |
| 2071 | const environment = environmentById.get(p.environmentId) ?? null; | |
| 2075 | 2072 | envs.push({ |
| 2076 | 2073 | environmentId: p.environmentId, |
| 2077 | 2074 | projectId: p.id, |
| 2078 | label, | |
| 2075 | label: environment?.label ?? p.environmentId, | |
| 2079 | 2076 | isPrimary, |
| 2077 | machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), | |
| 2080 | 2078 | }); |
| 2081 | 2079 | } |
| 2082 | 2080 | // Sort: primary first, then alphabetical |
apps/web/src/components/EnvironmentMachineIcon.tsx created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 2 | import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; | |
| 3 | import type { FunctionComponent, SVGProps } from "react"; | |
| 4 | ||
| 5 | // Lucide has no Apple desktops, so these two are drawn to its grammar (24 | |
| 6 | // unit grid, 2 unit stroke, round joins) and share its prop surface so callers | |
| 7 | // can swap freely. | |
| 8 | function LucideLike(props: SVGProps<SVGSVGElement>) { | |
| 9 | return ( | |
| 10 | <svg | |
| 11 | xmlns="http://www.w3.org/2000/svg" | |
| 12 | width="24" | |
| 13 | height="24" | |
| 14 | viewBox="0 0 24 24" | |
| 15 | fill="none" | |
| 16 | stroke="currentColor" | |
| 17 | strokeWidth="2" | |
| 18 | strokeLinecap="round" | |
| 19 | strokeLinejoin="round" | |
| 20 | {...props} | |
| 21 | /> | |
| 22 | ); | |
| 23 | } | |
| 24 | ||
| 25 | /** A Mac mini: squat rounded slab with a front-edge LED. */ | |
| 26 | export function MacMiniIcon(props: SVGProps<SVGSVGElement>) { | |
| 27 | return ( | |
| 28 | <LucideLike {...props}> | |
| 29 | <rect width="20" height="8" x="2" y="8" rx="2" /> | |
| 30 | <path d="M6 12h.01" /> | |
| 31 | </LucideLike> | |
| 32 | ); | |
| 33 | } | |
| 34 | ||
| 35 | /** A Mac Studio: the same slab twice as tall, ports along the front foot. */ | |
| 36 | export function MacStudioIcon(props: SVGProps<SVGSVGElement>) { | |
| 37 | return ( | |
| 38 | <LucideLike {...props}> | |
| 39 | <rect width="18" height="14" x="3" y="5" rx="2" /> | |
| 40 | <path d="M7 15h.01M11 15h.01M15 15h.01" /> | |
| 41 | </LucideLike> | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | const ICON_BY_KIND: Record<EnvironmentMachineKind, FunctionComponent<LucideProps>> = { | |
| 46 | server: ServerIcon, | |
| 47 | cloud: CloudIcon, | |
| 48 | desktop: MonitorIcon, | |
| 49 | laptop: LaptopIcon, | |
| 50 | "mac-mini": MacMiniIcon, | |
| 51 | "mac-studio": MacStudioIcon, | |
| 52 | }; | |
| 53 | ||
| 54 | export const ENVIRONMENT_MACHINE_KIND_LABELS: Record<EnvironmentMachineKind, string> = { | |
| 55 | server: "Server", | |
| 56 | cloud: "Cloud VM", | |
| 57 | desktop: "Desktop", | |
| 58 | laptop: "Laptop", | |
| 59 | "mac-mini": "Mac mini", | |
| 60 | "mac-studio": "Mac Studio", | |
| 61 | }; | |
| 62 | ||
| 63 | export function environmentMachineIcon( | |
| 64 | kind: EnvironmentMachineKind, | |
| 65 | ): FunctionComponent<LucideProps> { | |
| 66 | return ICON_BY_KIND[kind]; | |
| 67 | } | |
| 68 | ||
| 69 | export function EnvironmentMachineIcon({ | |
| 70 | kind, | |
| 71 | ...props | |
| 72 | }: LucideProps & { readonly kind: EnvironmentMachineKind }) { | |
| 73 | const Icon = ICON_BY_KIND[kind]; | |
| 74 | return <Icon {...props} />; | |
| 75 | } |
apps/web/src/components/LegacySidebar.tsx+10-3| ... | ... | @@ -22,6 +22,7 @@ import { |
| 22 | 22 | ThreadWorktreeIndicator, |
| 23 | 23 | useLinkedThreadPullRequest, |
| 24 | 24 | } from "./ThreadStatusIndicators"; |
| 25 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | |
| 25 | 26 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 26 | 27 | import { useAtomValue } from "@effect/atom-react"; |
| 27 | 28 | import { autoAnimate } from "@formkit/auto-animate"; |
| ... | ... | @@ -48,6 +49,7 @@ import { |
| 48 | 49 | type ScopedThreadRef, |
| 49 | 50 | type ResolvedKeybindingsConfig, |
| 50 | 51 | type SidebarProjectGroupingMode, |
| 52 | resolveEnvironmentMachineKind, | |
| 51 | 53 | ThreadId, |
| 52 | 54 | } from "@t3tools/contracts"; |
| 53 | 55 | import { |
| ... | ... | @@ -395,9 +397,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 395 | 397 | }); |
| 396 | 398 | const environment = useEnvironment(thread.environmentId); |
| 397 | 399 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 398 | const isRemoteThread = | |
| 399 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | |
| 400 | // No primary (the hosted app) means every thread is remote, and the machine | |
| 401 | // glyph is what tells the environments apart. | |
| 402 | const isRemoteThread = thread.environmentId !== primaryEnvironmentId; | |
| 400 | 403 | const remoteEnvLabel = environment?.label ?? null; |
| 404 | const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); | |
| 401 | 405 | // A desktop-local secondary backend (e.g. the WSL backend) shows up as a |
| 402 | 406 | // bearer environment whose connection id is prefixed "local:". It runs on the |
| 403 | 407 | // user's own machine, so the cloud icon is misleading — label it "Local" and |
| ... | ... | @@ -876,7 +880,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 876 | 880 | /> |
| 877 | 881 | } |
| 878 | 882 | > |
| 879 | <CloudIcon className="size-3 text-muted-foreground/40" /> | |
| 883 | <EnvironmentMachineIcon | |
| 884 | kind={remoteMachine} | |
| 885 | className="size-3 text-muted-foreground/40" | |
| 886 | /> | |
| 880 | 887 | </TooltipTrigger> |
| 881 | 888 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> |
| 882 | 889 | </Tooltip> |
apps/web/src/components/Sidebar.tsx+46-6| ... | ... | @@ -29,7 +29,12 @@ import { |
| 29 | 29 | scopeThreadRef, |
| 30 | 30 | scopedThreadKey, |
| 31 | 31 | } from "@t3tools/client-runtime/environment"; |
| 32 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 32 | import { | |
| 33 | resolveEnvironmentMachineKind, | |
| 34 | type EnvironmentMachineKind, | |
| 35 | type ScopedThreadRef, | |
| 36 | type ThreadId, | |
| 37 | } from "@t3tools/contracts"; | |
| 33 | 38 | import type { TimestampFormat } from "@t3tools/contracts/settings"; |
| 34 | 39 | import { |
| 35 | 40 | AlarmClockIcon, |
| ... | ... | @@ -47,7 +52,6 @@ import { |
| 47 | 52 | PinIcon, |
| 48 | 53 | PlusIcon, |
| 49 | 54 | SearchIcon, |
| 50 | ServerIcon, | |
| 51 | 55 | SettingsIcon, |
| 52 | 56 | SquarePenIcon, |
| 53 | 57 | TerminalIcon, |
| ... | ... | @@ -121,6 +125,7 @@ import { |
| 121 | 125 | import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; |
| 122 | 126 | import type { SidebarThreadSummary } from "../types"; |
| 123 | 127 | import { cn } from "~/lib/utils"; |
| 128 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | |
| 124 | 129 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 125 | 130 | import { |
| 126 | 131 | animatePinnedLayoutChanges, |
| ... | ... | @@ -274,6 +279,7 @@ function SidebarThreadTooltip({ |
| 274 | 279 | projectCwd, |
| 275 | 280 | projectFaviconPath, |
| 276 | 281 | environmentLabel, |
| 282 | environmentMachine, | |
| 277 | 283 | providerEntry, |
| 278 | 284 | showInstanceBadge, |
| 279 | 285 | modelInstanceId, |
| ... | ... | @@ -287,6 +293,7 @@ function SidebarThreadTooltip({ |
| 287 | 293 | projectCwd: string | null; |
| 288 | 294 | projectFaviconPath: string | null; |
| 289 | 295 | environmentLabel: string | null; |
| 296 | environmentMachine: EnvironmentMachineKind; | |
| 290 | 297 | providerEntry: ProviderInstanceEntry | null; |
| 291 | 298 | showInstanceBadge: boolean; |
| 292 | 299 | modelInstanceId: string; |
| ... | ... | @@ -325,7 +332,10 @@ function SidebarThreadTooltip({ |
| 325 | 332 | ) : null} |
| 326 | 333 | {environmentLabel ? ( |
| 327 | 334 | <div className="flex min-w-0 items-center gap-2"> |
| 328 | <ServerIcon className="size-3 shrink-0 stroke-muted-foreground" /> | |
| 335 | <EnvironmentMachineIcon | |
| 336 | kind={environmentMachine} | |
| 337 | className="size-3 shrink-0 stroke-muted-foreground" | |
| 338 | /> | |
| 329 | 339 | <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div> |
| 330 | 340 | </div> |
| 331 | 341 | ) : null} |
| ... | ... | @@ -735,6 +745,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 735 | 745 | jumpLabel: string | null; |
| 736 | 746 | currentEnvironmentId: string | null; |
| 737 | 747 | environmentLabel: string | null; |
| 748 | environmentMachine: EnvironmentMachineKind; | |
| 738 | 749 | projectCwd: string | null; |
| 739 | 750 | projectFaviconPath: string | null; |
| 740 | 751 | projectTitle: string | null; |
| ... | ... | @@ -963,8 +974,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 963 | 974 | ? getTriggerDisplayModelLabel(selectedModel) |
| 964 | 975 | : thread.modelSelection.model; |
| 965 | 976 | |
| 966 | const isRemote = | |
| 967 | props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; | |
| 977 | // The local environment is "this machine" and needs no marker; every other | |
| 978 | // one gets its machine glyph. With no local environment (the hosted app) | |
| 979 | // that is every thread, which is the point: the glyph is what tells rows on | |
| 980 | // different machines apart. | |
| 981 | const isRemote = thread.environmentId !== props.currentEnvironmentId; | |
| 968 | 982 | |
| 969 | 983 | const detailsTooltip = ( |
| 970 | 984 | <SidebarThreadTooltip |
| ... | ... | @@ -973,6 +987,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 973 | 987 | projectCwd={props.projectCwd} |
| 974 | 988 | projectFaviconPath={props.projectFaviconPath} |
| 975 | 989 | environmentLabel={props.environmentLabel} |
| 990 | environmentMachine={props.environmentMachine} | |
| 976 | 991 | providerEntry={providerEntry} |
| 977 | 992 | showInstanceBadge={showInstanceBadge} |
| 978 | 993 | modelInstanceId={modelInstanceId} |
| ... | ... | @@ -1590,7 +1605,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1590 | 1605 | > |
| 1591 | 1606 | {isRemote ? ( |
| 1592 | 1607 | <span className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70"> |
| 1593 | <ServerIcon aria-hidden className="size-3.5" /> | |
| 1608 | <EnvironmentMachineIcon | |
| 1609 | aria-hidden | |
| 1610 | kind={props.environmentMachine} | |
| 1611 | className="size-3.5" | |
| 1612 | /> | |
| 1594 | 1613 | </span> |
| 1595 | 1614 | ) : null} |
| 1596 | 1615 | {driverKind ? ( |
| ... | ... | @@ -1636,6 +1655,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1636 | 1655 | projectFaviconPath: string | null; |
| 1637 | 1656 | projectTitle: string | null; |
| 1638 | 1657 | environmentLabel: string | null; |
| 1658 | environmentMachine: EnvironmentMachineKind; | |
| 1639 | 1659 | providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>; |
| 1640 | 1660 | isHighlighted: boolean; |
| 1641 | 1661 | isRouteActive: boolean; |
| ... | ... | @@ -1731,6 +1751,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1731 | 1751 | projectCwd={props.projectCwd} |
| 1732 | 1752 | projectFaviconPath={props.projectFaviconPath} |
| 1733 | 1753 | environmentLabel={props.environmentLabel} |
| 1754 | environmentMachine={props.environmentMachine} | |
| 1734 | 1755 | providerEntry={providerEntry} |
| 1735 | 1756 | showInstanceBadge={showInstanceBadge} |
| 1736 | 1757 | modelInstanceId={modelInstanceId} |
| ... | ... | @@ -1871,6 +1892,19 @@ export default function Sidebar() { |
| 1871 | 1892 | ), |
| 1872 | 1893 | [environments], |
| 1873 | 1894 | ); |
| 1895 | const environmentMachineById = useMemo( | |
| 1896 | () => | |
| 1897 | new Map( | |
| 1898 | environments.map( | |
| 1899 | (environment) => | |
| 1900 | [ | |
| 1901 | environment.environmentId, | |
| 1902 | resolveEnvironmentMachineKind(environment.serverConfig), | |
| 1903 | ] as const, | |
| 1904 | ), | |
| 1905 | ), | |
| 1906 | [environments], | |
| 1907 | ); | |
| 1874 | 1908 | const orderedProjects = useMemo( |
| 1875 | 1909 | () => |
| 1876 | 1910 | orderItemsByPreferredIds({ |
| ... | ... | @@ -3717,6 +3751,9 @@ export default function Sidebar() { |
| 3717 | 3751 | ) ?? null |
| 3718 | 3752 | } |
| 3719 | 3753 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3754 | environmentMachine={ | |
| 3755 | environmentMachineById.get(thread.environmentId) ?? "server" | |
| 3756 | } | |
| 3720 | 3757 | providerEntryByInstanceId={ |
| 3721 | 3758 | providerEntriesByEnvironment.get(thread.environmentId) ?? |
| 3722 | 3759 | EMPTY_PROVIDER_ENTRIES |
| ... | ... | @@ -3815,6 +3852,9 @@ export default function Sidebar() { |
| 3815 | 3852 | } |
| 3816 | 3853 | currentEnvironmentId={primaryEnvironmentId} |
| 3817 | 3854 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3855 | environmentMachine={ | |
| 3856 | environmentMachineById.get(thread.environmentId) ?? "server" | |
| 3857 | } | |
| 3818 | 3858 | projectCwd={ |
| 3819 | 3859 | projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null |
| 3820 | 3860 | } |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+19-11| ... | ... | @@ -1,13 +1,14 @@ |
| 1 | 1 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 2 | 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { | |
| 4 | EnvironmentId, | |
| 5 | PullRequestAction, | |
| 6 | PullRequestMergeMethod, | |
| 7 | PullRequestUpdateMethod, | |
| 8 | PullRequestRef, | |
| 9 | PullRequestState, | |
| 10 | ScopedThreadRef, | |
| 3 | import { | |
| 4 | type EnvironmentId, | |
| 5 | type PullRequestAction, | |
| 6 | type PullRequestMergeMethod, | |
| 7 | type PullRequestUpdateMethod, | |
| 8 | type PullRequestRef, | |
| 9 | type PullRequestState, | |
| 10 | resolveEnvironmentMachineKind, | |
| 11 | type ScopedThreadRef, | |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import { |
| 13 | 14 | ArrowDownUpIcon, |
| ... | ... | @@ -36,7 +37,6 @@ import { |
| 36 | 37 | PlayIcon, |
| 37 | 38 | RefreshCwIcon, |
| 38 | 39 | RotateCcwIcon, |
| 39 | ServerIcon, | |
| 40 | 40 | TriangleAlertIcon, |
| 41 | 41 | } from "lucide-react"; |
| 42 | 42 | import { |
| ... | ... | @@ -77,6 +77,7 @@ import { |
| 77 | 77 | AlertDialogPopup, |
| 78 | 78 | AlertDialogTitle, |
| 79 | 79 | } from "../ui/alert-dialog"; |
| 80 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | |
| 80 | 81 | import { Badge } from "../ui/badge"; |
| 81 | 82 | import { Button } from "../ui/button"; |
| 82 | 83 | import { Input } from "../ui/input"; |
| ... | ... | @@ -272,7 +273,10 @@ function ActOnEnvironmentPicker({ |
| 272 | 273 | {/* The radio item lays its children out as one block, so the icon and the label |
| 273 | 274 | need their own row to share a line. */} |
| 274 | 275 | <span className="flex min-w-0 items-center gap-2"> |
| 275 | <ServerIcon className="size-3.5 shrink-0" /> | |
| 276 | <EnvironmentMachineIcon | |
| 277 | kind={environment.machine ?? "server"} | |
| 278 | className="size-3.5 shrink-0" | |
| 279 | /> | |
| 276 | 280 | <span className="truncate">{environment.label}</span> |
| 277 | 281 | </span> |
| 278 | 282 | </MenuRadioItem> |
| ... | ... | @@ -729,7 +733,11 @@ export function PullRequestDetailPanel({ |
| 729 | 733 | ? resolvePickableEnvironments( |
| 730 | 734 | { environmentId, projectId: reference.projectId }, |
| 731 | 735 | projects, |
| 732 | environments, | |
| 736 | environments.map((environment) => ({ | |
| 737 | environmentId: environment.environmentId, | |
| 738 | label: environment.label, | |
| 739 | machine: resolveEnvironmentMachineKind(environment.serverConfig), | |
| 740 | })), | |
| 733 | 741 | ) |
| 734 | 742 | : [], |
| 735 | 743 | [context, environmentId, environments, projects, reference.projectId], |
apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts+13-6| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | /** The little of a project this needs: who holds it, and which repository it is a copy of. */ |
| 4 | 4 | export interface AssignableProject { |
| ... | ... | @@ -69,6 +69,7 @@ export interface PickableEnvironment { |
| 69 | 69 | readonly projectId: ProjectId; |
| 70 | 70 | readonly workspaceRoot: string; |
| 71 | 71 | readonly label: string; |
| 72 | readonly machine?: EnvironmentMachineKind; | |
| 72 | 73 | } |
| 73 | 74 | |
| 74 | 75 | /** |
| ... | ... | @@ -85,17 +86,21 @@ export interface PickableEnvironment { |
| 85 | 86 | export function resolvePickableEnvironments( |
| 86 | 87 | current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, |
| 87 | 88 | projects: ReadonlyArray<AssignableProject & { readonly workspaceRoot: string }>, |
| 88 | environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>, | |
| 89 | environments: ReadonlyArray<{ | |
| 90 | readonly environmentId: EnvironmentId; | |
| 91 | readonly label: string; | |
| 92 | readonly machine?: EnvironmentMachineKind; | |
| 93 | }>, | |
| 89 | 94 | ): ReadonlyArray<PickableEnvironment> { |
| 90 | 95 | const own = projects.find( |
| 91 | 96 | (project) => |
| 92 | 97 | project.environmentId === current.environmentId && project.id === current.projectId, |
| 93 | 98 | ); |
| 94 | 99 | const key = own === undefined ? undefined : repositoryKey(own); |
| 95 | const ownLabel = environments.find( | |
| 100 | const ownEnvironment = environments.find( | |
| 96 | 101 | (environment) => environment.environmentId === current.environmentId, |
| 97 | )?.label; | |
| 98 | if (own === undefined || !key || ownLabel === undefined) return []; | |
| 102 | ); | |
| 103 | if (own === undefined || !key || ownEnvironment === undefined) return []; | |
| 99 | 104 | const others = environments.flatMap((environment) => { |
| 100 | 105 | if (environment.environmentId === current.environmentId) return []; |
| 101 | 106 | // One entry per server, whichever copy comes first: a server holding two worktrees of the |
| ... | ... | @@ -112,6 +117,7 @@ export function resolvePickableEnvironments( |
| 112 | 117 | projectId: copy.id, |
| 113 | 118 | workspaceRoot: copy.workspaceRoot, |
| 114 | 119 | label: environment.label, |
| 120 | ...(environment.machine === undefined ? {} : { machine: environment.machine }), | |
| 115 | 121 | }, |
| 116 | 122 | ]; |
| 117 | 123 | }); |
| ... | ... | @@ -123,7 +129,8 @@ export function resolvePickableEnvironments( |
| 123 | 129 | environmentId: current.environmentId, |
| 124 | 130 | projectId: own.id, |
| 125 | 131 | workspaceRoot: own.workspaceRoot, |
| 126 | label: ownLabel, | |
| 132 | label: ownEnvironment.label, | |
| 133 | ...(ownEnvironment.machine === undefined ? {} : { machine: ownEnvironment.machine }), | |
| 127 | 134 | }, |
| 128 | 135 | ...others, |
| 129 | 136 | ]; |
apps/web/src/components/settings/ConnectionsSettings.tsx+29| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | type DesktopServerExposureState, |
| 28 | 28 | type DesktopWslState, |
| 29 | 29 | type EnvironmentId, |
| 30 | resolveEnvironmentMachineKind, | |
| 30 | 31 | } from "@t3tools/contracts"; |
| 31 | 32 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 32 | 33 | import { |
| ... | ... | @@ -53,6 +54,7 @@ import { |
| 53 | 54 | useRelativeTimeTick, |
| 54 | 55 | } from "./settingsLayout"; |
| 55 | 56 | import { searchableSetting } from "./settingsSearch"; |
| 57 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; | |
| 56 | 58 | import { Input } from "../ui/input"; |
| 57 | 59 | import { Checkbox } from "../ui/checkbox"; |
| 58 | 60 | import { |
| ... | ... | @@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 86 | 88 | import { Button } from "../ui/button"; |
| 87 | 89 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; |
| 88 | 90 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 91 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | |
| 89 | 92 | import { Textarea } from "../ui/textarea"; |
| 90 | 93 | import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; |
| 91 | 94 | import { readHostedPairingRequest } from "../../hostedPairing"; |
| ... | ... | @@ -1438,6 +1441,11 @@ function SavedBackendListRow({ |
| 1438 | 1441 | : null |
| 1439 | 1442 | } |
| 1440 | 1443 | /> |
| 1444 | <EnvironmentMachineIcon | |
| 1445 | aria-hidden | |
| 1446 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | |
| 1447 | className="size-3.5 shrink-0 text-muted-foreground" | |
| 1448 | /> | |
| 1441 | 1449 | <h3 className="min-w-0 truncate text-sm font-medium text-foreground"> |
| 1442 | 1450 | {environment.label} |
| 1443 | 1451 | </h3> |
| ... | ... | @@ -1445,6 +1453,15 @@ function SavedBackendListRow({ |
| 1445 | 1453 | {metadataBits.length > 0 ? ( |
| 1446 | 1454 | <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> |
| 1447 | 1455 | ) : null} |
| 1456 | {isConnected ? ( | |
| 1457 | <div className="pt-1"> | |
| 1458 | <EnvironmentIconPicker | |
| 1459 | environmentId={environmentId} | |
| 1460 | serverConfig={environment.serverConfig} | |
| 1461 | size="xs" | |
| 1462 | /> | |
| 1463 | </div> | |
| 1464 | ) : null} | |
| 1448 | 1465 | {serverUpdateState.status !== "idle" ? ( |
| 1449 | 1466 | <div className="max-w-md"> |
| 1450 | 1467 | <ServerUpdateProgress state={serverUpdateState} /> |
| ... | ... | @@ -3081,6 +3098,18 @@ export function ConnectionsSettings() { |
| 3081 | 3098 | } |
| 3082 | 3099 | /> |
| 3083 | 3100 | ) : null} |
| 3101 | {primaryEnvironmentId !== null ? ( | |
| 3102 | <SettingsRow | |
| 3103 | {...searchableSetting("environment-icon")} | |
| 3104 | description="The machine other devices see this environment as. Automatic uses what the server detected." | |
| 3105 | control={ | |
| 3106 | <EnvironmentIconPicker | |
| 3107 | environmentId={primaryEnvironmentId} | |
| 3108 | serverConfig={primaryServerConfig} | |
| 3109 | /> | |
| 3110 | } | |
| 3111 | /> | |
| 3112 | ) : null} | |
| 3084 | 3113 | {desktopBridge ? ( |
| 3085 | 3114 | <> |
| 3086 | 3115 | {renderNetworkAccessRow()} |
apps/web/src/components/settings/EnvironmentIconPicker.test.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import type { ServerConfig } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { resolveEnvironmentIconPickerLock } from "./EnvironmentIconPicker"; | |
| 5 | ||
| 6 | const config = (environmentIcon: boolean | undefined) => | |
| 7 | ({ | |
| 8 | environment: { capabilities: environmentIcon === undefined ? {} : { environmentIcon } }, | |
| 9 | }) as unknown as ServerConfig; | |
| 10 | ||
| 11 | describe("resolveEnvironmentIconPickerLock", () => { | |
| 12 | it("locks until the environment is connected", () => { | |
| 13 | expect( | |
| 14 | resolveEnvironmentIconPickerLock({ serverConfig: null, operateAccess: "granted" }), | |
| 15 | ).toMatch(/Connect/); | |
| 16 | }); | |
| 17 | ||
| 18 | it("locks on servers that predate the setting, before looking at permissions", () => { | |
| 19 | expect( | |
| 20 | resolveEnvironmentIconPickerLock({ | |
| 21 | serverConfig: config(undefined), | |
| 22 | operateAccess: "denied", | |
| 23 | }), | |
| 24 | ).toMatch(/too old/); | |
| 25 | }); | |
| 26 | ||
| 27 | it("locks when the session cannot operate the environment", () => { | |
| 28 | expect( | |
| 29 | resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "denied" }), | |
| 30 | ).toMatch(/cannot change/); | |
| 31 | }); | |
| 32 | ||
| 33 | it("stays open while access is still resolving so a slow session does not flicker", () => { | |
| 34 | expect( | |
| 35 | resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), | |
| 36 | ).toBeNull(); | |
| 37 | expect( | |
| 38 | resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }), | |
| 39 | ).toBeNull(); | |
| 40 | }); | |
| 41 | }); |
apps/web/src/components/settings/EnvironmentIconPicker.tsx created+161| ... | ... | @@ -0,0 +1,161 @@ |
| 1 | import { | |
| 2 | ENVIRONMENT_MACHINE_KINDS, | |
| 3 | isEnvironmentMachineKind, | |
| 4 | resolveEnvironmentMachineKind, | |
| 5 | type EnvironmentId, | |
| 6 | type ServerConfig, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { useCallback } from "react"; | |
| 9 | ||
| 10 | import { isElectron } from "../../env"; | |
| 11 | import { usePrimarySessionState } from "../../environments/primary"; | |
| 12 | import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | |
| 13 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 14 | import { useEnvironmentSessionState } from "../../state/session"; | |
| 15 | import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | |
| 16 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 18 | import { | |
| 19 | resolvePrimaryOperateAccess, | |
| 20 | resolveRemoteOperateAccess, | |
| 21 | } from "./ProviderSettingsPanel.logic"; | |
| 22 | ||
| 23 | const AUTOMATIC_VALUE = "automatic"; | |
| 24 | ||
| 25 | /** | |
| 26 | * Why the picker is inert, in the order the user can do something about it. | |
| 27 | * Null means it can be changed. | |
| 28 | */ | |
| 29 | export function resolveEnvironmentIconPickerLock(input: { | |
| 30 | readonly serverConfig: ServerConfig | null; | |
| 31 | readonly operateAccess: "granted" | "denied" | "pending"; | |
| 32 | }): string | null { | |
| 33 | if (input.serverConfig === null) { | |
| 34 | return "Connect to this environment to change its icon."; | |
| 35 | } | |
| 36 | if (input.serverConfig.environment.capabilities.environmentIcon !== true) { | |
| 37 | return "This environment's server is too old to keep an icon. Update it to choose one."; | |
| 38 | } | |
| 39 | if (input.operateAccess === "denied") { | |
| 40 | return "Your session on this environment cannot change its settings."; | |
| 41 | } | |
| 42 | return null; | |
| 43 | } | |
| 44 | ||
| 45 | // Same split the provider settings use: the desktop app owns its primary | |
| 46 | // server outright, a browser session on the primary checks its cookie | |
| 47 | // session's scopes, and a remote checks the scopes its own server reports. | |
| 48 | function useEnvironmentOperateAccess(environmentId: EnvironmentId) { | |
| 49 | const isPrimary = usePrimaryEnvironmentId() === environmentId; | |
| 50 | const primarySession = usePrimarySessionState(); | |
| 51 | const remoteSession = useEnvironmentSessionState(environmentId); | |
| 52 | if (isPrimary) { | |
| 53 | return isElectron | |
| 54 | ? "granted" | |
| 55 | : resolvePrimaryOperateAccess({ | |
| 56 | isPrimary: true, | |
| 57 | hasDesktopBridge: false, | |
| 58 | session: primarySession.data, | |
| 59 | isPending: primarySession.isPending, | |
| 60 | hasError: primarySession.error !== null, | |
| 61 | }); | |
| 62 | } | |
| 63 | return resolveRemoteOperateAccess({ | |
| 64 | session: remoteSession.data, | |
| 65 | isPending: remoteSession.isPending, | |
| 66 | hasError: remoteSession.hasError, | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Picks the machine glyph an environment wears everywhere it is listed. | |
| 72 | * "Automatic" clears the override so the server's own detection shows | |
| 73 | * through; the label says what that currently resolves to so the user can | |
| 74 | * tell whether detection got it right before overriding. The control stays | |
| 75 | * visible while locked so the current icon still reads, the same way | |
| 76 | * server-scoped rows go inert instead of disappearing. | |
| 77 | */ | |
| 78 | export function EnvironmentIconPicker({ | |
| 79 | environmentId, | |
| 80 | serverConfig, | |
| 81 | size = "sm", | |
| 82 | }: { | |
| 83 | readonly environmentId: EnvironmentId; | |
| 84 | readonly serverConfig: ServerConfig | null; | |
| 85 | readonly size?: "xs" | "sm"; | |
| 86 | }) { | |
| 87 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | |
| 88 | const operateAccess = useEnvironmentOperateAccess(environmentId); | |
| 89 | const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); | |
| 90 | const override = serverConfig?.settings.environmentIcon ?? null; | |
| 91 | const detected = serverConfig?.environment.platform.machine ?? null; | |
| 92 | const resolved = resolveEnvironmentMachineKind(serverConfig); | |
| 93 | const value = override ?? AUTOMATIC_VALUE; | |
| 94 | const automaticLabel = | |
| 95 | detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`; | |
| 96 | ||
| 97 | const handleValueChange = useCallback( | |
| 98 | (next: string | null) => { | |
| 99 | if (next === null) return; | |
| 100 | if (next === AUTOMATIC_VALUE) { | |
| 101 | updateSettings({ environmentIcon: null }); | |
| 102 | } else if (isEnvironmentMachineKind(next)) { | |
| 103 | updateSettings({ environmentIcon: next }); | |
| 104 | } | |
| 105 | }, | |
| 106 | [updateSettings], | |
| 107 | ); | |
| 108 | ||
| 109 | const select = ( | |
| 110 | <Select value={value} onValueChange={handleValueChange} disabled={lock !== null}> | |
| 111 | <SelectTrigger size={size} className="w-full sm:w-52" aria-label="Environment icon"> | |
| 112 | <SelectValue> | |
| 113 | <span className="flex min-w-0 items-center gap-2"> | |
| 114 | <EnvironmentMachineIcon kind={resolved} className="size-3.5 shrink-0" /> | |
| 115 | <span className="truncate"> | |
| 116 | {override === null ? automaticLabel : ENVIRONMENT_MACHINE_KIND_LABELS[override]} | |
| 117 | </span> | |
| 118 | </span> | |
| 119 | </SelectValue> | |
| 120 | </SelectTrigger> | |
| 121 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 122 | <SelectItem hideIndicator value={AUTOMATIC_VALUE}> | |
| 123 | <span className="flex min-w-0 items-center gap-2"> | |
| 124 | <EnvironmentMachineIcon kind={detected ?? "server"} className="size-3.5 shrink-0" /> | |
| 125 | <span className="truncate">{automaticLabel}</span> | |
| 126 | </span> | |
| 127 | </SelectItem> | |
| 128 | {ENVIRONMENT_MACHINE_KINDS.map((kind) => ( | |
| 129 | <SelectItem hideIndicator key={kind} value={kind}> | |
| 130 | <span className="flex min-w-0 items-center gap-2"> | |
| 131 | <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" /> | |
| 132 | <span className="truncate">{ENVIRONMENT_MACHINE_KIND_LABELS[kind]}</span> | |
| 133 | </span> | |
| 134 | </SelectItem> | |
| 135 | ))} | |
| 136 | </SelectPopup> | |
| 137 | </Select> | |
| 138 | ); | |
| 139 | ||
| 140 | if (lock === null) { | |
| 141 | return select; | |
| 142 | } | |
| 143 | return ( | |
| 144 | <Tooltip> | |
| 145 | <TooltipTrigger | |
| 146 | render={ | |
| 147 | // Focusable so keyboard users can still reach the explanation. | |
| 148 | <span | |
| 149 | tabIndex={0} | |
| 150 | className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto" | |
| 151 | /> | |
| 152 | } | |
| 153 | > | |
| 154 | <span className="flex w-full items-center sm:w-auto">{select}</span> | |
| 155 | </TooltipTrigger> | |
| 156 | <TooltipPopup side="top" className="max-w-72"> | |
| 157 | {lock} | |
| 158 | </TooltipPopup> | |
| 159 | </Tooltip> | |
| 160 | ); | |
| 161 | } |
apps/web/src/components/settings/settingsSearch.ts+8| ... | ... | @@ -406,6 +406,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 406 | 406 | ], |
| 407 | 407 | primaryOnly: true, |
| 408 | 408 | }, |
| 409 | { | |
| 410 | id: "environment-icon", | |
| 411 | title: "Environment icon", | |
| 412 | to: "/settings/connections", | |
| 413 | targetId: "connections-environment", | |
| 414 | searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], | |
| 415 | localBackendManagementOnly: true, | |
| 416 | }, | |
| 409 | 417 | { |
| 410 | 418 | id: "network-access", |
| 411 | 419 | title: "Network access", |
apps/web/src/routes/_chat.pull-requests.tsx+5-6| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { pullRequestHostOf, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; | |
| 3 | 3 | import type { |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | ProjectId, |
| ... | ... | @@ -19,8 +19,6 @@ import { |
| 19 | 19 | ChevronDownIcon, |
| 20 | 20 | ClockIcon, |
| 21 | 21 | EyeIcon, |
| 22 | MonitorIcon, | |
| 23 | ServerIcon, | |
| 24 | 22 | GitMergeIcon, |
| 25 | 23 | GitPullRequestClosedIcon, |
| 26 | 24 | GitPullRequestIcon, |
| ... | ... | @@ -86,6 +84,7 @@ import { |
| 86 | 84 | writePullRequestListPreferences, |
| 87 | 85 | } from "../components/pullRequest/pullRequestListPreferences"; |
| 88 | 86 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; |
| 87 | import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; | |
| 89 | 88 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 90 | 89 | import { |
| 91 | 90 | PullRequestFiltersMenu, |
| ... | ... | @@ -1736,14 +1735,14 @@ function PullRequestsRouteView() { |
| 1736 | 1735 | }; |
| 1737 | 1736 | }), |
| 1738 | 1737 | ]; |
| 1739 | // The same shape the host pills take, so the two groups read as one control. A local | |
| 1740 | // connection wears the screen it is on; every other server wears a server. | |
| 1738 | // The same shape the host pills take, so the two groups read as one control. Each server | |
| 1739 | // wears the machine it runs on. | |
| 1741 | 1740 | const serverMenuOptions: ReadonlyArray<PullRequestFilterOption<string>> = [ |
| 1742 | 1741 | { value: "", label: "All servers", Icon: LayersIcon }, |
| 1743 | 1742 | ...capableEnvironments.map((environment) => ({ |
| 1744 | 1743 | value: environment.environmentId, |
| 1745 | 1744 | label: environment.label, |
| 1746 | Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon, | |
| 1745 | Icon: environmentMachineIcon(resolveEnvironmentMachineKind(environment.serverConfig)), | |
| 1747 | 1746 | })), |
| 1748 | 1747 | ]; |
| 1749 | 1748 | const sortMenu = ( |
docs/internals/remote.md+17| ... | ... | @@ -224,6 +224,22 @@ right action without making the transport responsible for process management. Th |
| 224 | 224 | supervisor owns the resulting disconnect and reconnect like any other involuntary close. See |
| 225 | 225 | [server-updates.md](./server-updates.md). |
| 226 | 226 | |
| 227 | ## Machine identity | |
| 228 | ||
| 229 | Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in | |
| 230 | contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the | |
| 231 | precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift: | |
| 232 | ||
| 233 | 1. `settings.environmentIcon`, a nullable server setting the user picks in Settings → Connections. | |
| 234 | It is server state rather than client state so every device connecting to that server agrees. | |
| 235 | 2. `environment.platform.machine`, detected once at startup by [ServerEnvironmentMachine.ts][machine] | |
| 236 | (IOKit product name / `hw.model` on macOS, `/sys/class/dmi/id` on Linux with virtualization | |
| 237 | markers mapped to "cloud"). Absent when there is no signal, so older servers, Windows hosts, and | |
| 238 | containers decode without it. | |
| 239 | 3. A generic server otherwise. | |
| 240 | ||
| 241 | Clients treat the field like any other capability: absent means "use the fallback", never "wait". | |
| 242 | ||
| 227 | 243 | ## Future work |
| 228 | 244 | |
| 229 | 245 | These remain unbuilt and are listed to keep the model honest: |
| ... | ... | @@ -237,3 +253,4 @@ These remain unbuilt and are listed to keep the model honest: |
| 237 | 253 | [authremote]: ../../packages/client-runtime/src/authorization/remote.ts |
| 238 | 254 | [sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts |
| 239 | 255 | [sshtunnel]: ../../packages/ssh/src/tunnel.ts |
| 256 | [machine]: ../../apps/server/src/environment/ServerEnvironmentMachine.ts |
docs/user/thread-sidebar.md+15| ... | ... | @@ -53,6 +53,21 @@ The main sidebar, right panel, and terminal drawer open and close immediately by |
| 53 | 53 | The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at |
| 54 | 54 | 0 ms, it snaps between the same open and closed states. |
| 55 | 55 | |
| 56 | ## Environment icons | |
| 57 | ||
| 58 | When you are connected to more than one environment, every thread that lives somewhere other than | |
| 59 | the machine you are on wears a small icon for that machine at the end of its row: a server, a cloud | |
| 60 | VM, a desktop, a laptop, a Mac mini, or a Mac Studio. In the hosted web app and the mobile app, | |
| 61 | where every environment is remote, each row wears its machine so you can tell them apart at a | |
| 62 | glance. The same icon appears in the thread tooltip, the "Run on" picker, the pull request server | |
| 63 | filter, and the environment lists under **Settings → Connections**. | |
| 64 | ||
| 65 | Servers pick the icon themselves from the hardware they run on. A Mac reports its model, a Linux | |
| 66 | machine reports its chassis type and whether it is a virtual machine, and anything without a usable | |
| 67 | signal shows a generic server. To override it, open **Settings → Connections** and choose an icon | |
| 68 | for that environment; **Automatic** goes back to what the server detected. The choice is stored on | |
| 69 | that server, so every device that connects to it sees the same icon. | |
| 70 | ||
| 56 | 71 | ## Environment artwork |
| 57 | 72 | |
| 58 | 73 | Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in |
packages/contracts/src/baseSchemas.ts+39| ... | ... | @@ -43,6 +43,45 @@ export type IsoDateTime = typeof IsoDateTime.Type; |
| 43 | 43 | * rejecting the payload would take down the connection over data the client |
| 44 | 44 | * couldn't act on anyway. Encoding is the plain array encoding. |
| 45 | 45 | */ |
| 46 | /** | |
| 47 | * Same idea for one optional value whose literal set grows over time: a | |
| 48 | * member this build does not know decodes as absent rather than failing the | |
| 49 | * enclosing struct. Encoding is the plain encoding. | |
| 50 | */ | |
| 51 | export const ForwardCompatibleOptional = <Value extends Schema.Top>(value: Value) => { | |
| 52 | const decodeValue = Schema.decodeUnknownOption(value as never); | |
| 53 | return Schema.optionalKey( | |
| 54 | Schema.Unknown.pipe( | |
| 55 | Schema.decodeTo( | |
| 56 | Schema.UndefinedOr(value), | |
| 57 | SchemaTransformation.transform<Value["Encoded"] | undefined, unknown>({ | |
| 58 | decode: (raw) => | |
| 59 | Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : undefined, | |
| 60 | encode: (raw) => raw, | |
| 61 | }), | |
| 62 | ), | |
| 63 | ), | |
| 64 | ); | |
| 65 | }; | |
| 66 | ||
| 67 | /** | |
| 68 | * The nullable form, for a persisted setting whose literal set grows over | |
| 69 | * time: a member this build does not know (or a missing key) decodes as null | |
| 70 | * rather than failing the enclosing struct. Encoding is the plain encoding. | |
| 71 | */ | |
| 72 | export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value) => { | |
| 73 | const decodeValue = Schema.decodeUnknownOption(value as never); | |
| 74 | return Schema.Unknown.pipe( | |
| 75 | Schema.decodeTo( | |
| 76 | Schema.NullOr(value), | |
| 77 | SchemaTransformation.transform<Value["Encoded"] | null, unknown>({ | |
| 78 | decode: (raw) => (Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : null), | |
| 79 | encode: (raw) => raw, | |
| 80 | }), | |
| 81 | ), | |
| 82 | ); | |
| 83 | }; | |
| 84 | ||
| 46 | 85 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => { |
| 47 | 86 | const decodeElement = Schema.decodeUnknownOption(element as never); |
| 48 | 87 | return Schema.Array(Schema.Unknown).pipe( |
packages/contracts/src/environment.ts+32-1| ... | ... | @@ -1,7 +1,13 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | import { | |
| 5 | EnvironmentId, | |
| 6 | ForwardCompatibleOptional, | |
| 7 | ProjectId, | |
| 8 | ThreadId, | |
| 9 | TrimmedNonEmptyString, | |
| 10 | } from "./baseSchemas.ts"; | |
| 5 | 11 | |
| 6 | 12 | export const ExecutionEnvironmentPlatformOs = Schema.Literals([ |
| 7 | 13 | "darwin", |
| ... | ... | @@ -14,9 +20,30 @@ export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatform |
| 14 | 20 | export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); |
| 15 | 21 | export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; |
| 16 | 22 | |
| 23 | /** | |
| 24 | * The curated set of machine shapes an environment can wear as its icon. | |
| 25 | * Servers detect one from the hardware they run on (`platform.machine`), and | |
| 26 | * the `environmentIcon` server setting lets a user pick one instead. | |
| 27 | */ | |
| 28 | export const ENVIRONMENT_MACHINE_KINDS = [ | |
| 29 | "server", | |
| 30 | "cloud", | |
| 31 | "desktop", | |
| 32 | "laptop", | |
| 33 | "mac-mini", | |
| 34 | "mac-studio", | |
| 35 | ] as const; | |
| 36 | export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS); | |
| 37 | export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type; | |
| 38 | export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind); | |
| 39 | ||
| 17 | 40 | export const ExecutionEnvironmentPlatform = Schema.Struct({ |
| 18 | 41 | os: ExecutionEnvironmentPlatformOs, |
| 19 | 42 | arch: ExecutionEnvironmentPlatformArch, |
| 43 | /** Hardware shape detected at startup. Absent when the host gives no usable | |
| 44 | signal (containers, Windows, unknown DMI), on servers that predate it, or | |
| 45 | when a newer server names a kind this build cannot draw. */ | |
| 46 | machine: ForwardCompatibleOptional(EnvironmentMachineKind), | |
| 20 | 47 | }); |
| 21 | 48 | |
| 22 | 49 | /** |
| ... | ... | @@ -102,6 +129,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 102 | 129 | this is false — no update would ever repaint it. Absent on older |
| 103 | 130 | servers, which may still publish, so only an explicit false skips. */ |
| 104 | 131 | agentActivityPublishing: Schema.optionalKey(Schema.Boolean), |
| 132 | /** Server detects `platform.machine` and persists the `environmentIcon` | |
| 133 | setting. Older servers drop the key on write, so clients show the | |
| 134 | picker inert rather than offering a choice that would never stick. */ | |
| 135 | environmentIcon: Schema.optionalKey(Schema.Boolean), | |
| 105 | 136 | /** The desktop app supervising this server can be driven over RPC: |
| 106 | 137 | server.updateServer runs its check -> download -> relaunch. Absent on |
| 107 | 138 | desktop servers whose app predates the remote trigger, where clients |
packages/contracts/src/server.test.ts+53| ... | ... | @@ -1,12 +1,15 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | |
| 4 | 5 | import { |
| 6 | resolveEnvironmentMachineKind, | |
| 5 | 7 | ServerConfig, |
| 6 | 8 | ServerProvider, |
| 7 | 9 | ServerProviders, |
| 8 | 10 | ServerUpsertKeybindingResult, |
| 9 | 11 | } from "./server.ts"; |
| 12 | import { ServerSettings } from "./settings.ts"; | |
| 10 | 13 | |
| 11 | 14 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); |
| 12 | 15 | const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); |
| ... | ... | @@ -155,3 +158,53 @@ describe("server config forward compatibility", () => { |
| 155 | 158 | expect(parsed).toEqual([decodedBase]); |
| 156 | 159 | }); |
| 157 | 160 | }); |
| 161 | ||
| 162 | describe("resolveEnvironmentMachineKind", () => { | |
| 163 | const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); | |
| 164 | const decodeSettings = Schema.decodeUnknownSync(ServerSettings); | |
| 165 | const descriptor = (platform: Record<string, unknown>) => | |
| 166 | decodeDescriptor({ | |
| 167 | environmentId: "env-1", | |
| 168 | label: "Box", | |
| 169 | platform: { os: "linux", arch: "x64", ...platform }, | |
| 170 | serverVersion: "1.0.0", | |
| 171 | capabilities: {}, | |
| 172 | }); | |
| 173 | ||
| 174 | it("prefers the user's pick over what the server detected", () => { | |
| 175 | expect( | |
| 176 | resolveEnvironmentMachineKind({ | |
| 177 | environment: descriptor({ machine: "mac-mini" }), | |
| 178 | settings: decodeSettings({ environmentIcon: "laptop" }), | |
| 179 | }), | |
| 180 | ).toBe("laptop"); | |
| 181 | }); | |
| 182 | ||
| 183 | it("uses detection when nothing is picked", () => { | |
| 184 | expect( | |
| 185 | resolveEnvironmentMachineKind({ | |
| 186 | environment: descriptor({ machine: "mac-mini" }), | |
| 187 | settings: decodeSettings({}), | |
| 188 | }), | |
| 189 | ).toBe("mac-mini"); | |
| 190 | }); | |
| 191 | ||
| 192 | it("falls back to a server for older servers and before connect", () => { | |
| 193 | expect( | |
| 194 | resolveEnvironmentMachineKind({ | |
| 195 | environment: descriptor({}), | |
| 196 | settings: decodeSettings({}), | |
| 197 | }), | |
| 198 | ).toBe("server"); | |
| 199 | expect(resolveEnvironmentMachineKind(null)).toBe("server"); | |
| 200 | }); | |
| 201 | ||
| 202 | it("drops a machine kind this build does not know instead of failing the descriptor", () => { | |
| 203 | const parsed = descriptor({ machine: "toaster" }); | |
| 204 | ||
| 205 | expect(parsed.platform.machine).toBeUndefined(); | |
| 206 | expect( | |
| 207 | resolveEnvironmentMachineKind({ environment: parsed, settings: decodeSettings({}) }), | |
| 208 | ).toBe("server"); | |
| 209 | }); | |
| 210 | }); |
packages/contracts/src/server.ts+17-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; | |
| 3 | import { | |
| 4 | type EnvironmentMachineKind, | |
| 5 | ExecutionEnvironmentDescriptor, | |
| 6 | ServerSelfUpdateMethod, | |
| 7 | } from "./environment.ts"; | |
| 4 | 8 | import { ServerAuthDescriptor } from "./auth.ts"; |
| 5 | 9 | import { |
| 6 | 10 | ForwardCompatibleArray, |
| ... | ... | @@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({ |
| 572 | 576 | }); |
| 573 | 577 | export type ServerConfig = typeof ServerConfig.Type; |
| 574 | 578 | |
| 579 | /** | |
| 580 | * The machine an environment should be drawn as: the user's pick, else what | |
| 581 | * the server detected, else a generic server. A null config (not connected | |
| 582 | * yet, or an older server) resolves to the same generic so rows never | |
| 583 | * flicker between glyphs. | |
| 584 | */ | |
| 585 | export function resolveEnvironmentMachineKind( | |
| 586 | config: Pick<ServerConfig, "environment" | "settings"> | null, | |
| 587 | ): EnvironmentMachineKind { | |
| 588 | return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server"; | |
| 589 | } | |
| 590 | ||
| 575 | 591 | const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ |
| 576 | 592 | key: KeybindingValue, |
| 577 | 593 | command: KeybindingCommand, |
packages/contracts/src/settings.test.ts+19| ... | ... | @@ -505,3 +505,22 @@ describe("ServerSettingsPatch string normalization", () => { |
| 505 | 505 | expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); |
| 506 | 506 | }); |
| 507 | 507 | }); |
| 508 | ||
| 509 | describe("ServerSettings environment icon", () => { | |
| 510 | it("defaults to null", () => { | |
| 511 | expect(decodeServerSettings({}).environmentIcon).toBeNull(); | |
| 512 | }); | |
| 513 | ||
| 514 | it("keeps a kind this build knows", () => { | |
| 515 | expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini"); | |
| 516 | }); | |
| 517 | ||
| 518 | it("decodes a kind from a newer server as null instead of failing the snapshot", () => { | |
| 519 | expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull(); | |
| 520 | }); | |
| 521 | ||
| 522 | it("round-trips through encode", () => { | |
| 523 | const settings = decodeServerSettings({ environmentIcon: "laptop" }); | |
| 524 | expect(encodeServerSettings(settings).environmentIcon).toBe("laptop"); | |
| 525 | }); | |
| 526 | }); |
packages/contracts/src/settings.ts+13-2| ... | ... | @@ -2,8 +2,8 @@ import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; | |
| 6 | import { ThreadEnvMode } from "./environment.ts"; | |
| 5 | import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; | |
| 6 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; | |
| 7 | 7 | import { |
| 8 | 8 | DEFAULT_TEXT_GENERATION_MODEL, |
| 9 | 9 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| ... | ... | @@ -734,6 +734,16 @@ export const ServerSettings = Schema.Struct({ |
| 734 | 734 | defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( |
| 735 | 735 | Schema.withDecodingDefault(Effect.succeed("")), |
| 736 | 736 | ), |
| 737 | /** | |
| 738 | * The icon clients draw for this environment. Null means "use what the | |
| 739 | * server detected" (`environment.platform.machine`), falling back to a | |
| 740 | * generic server. Lives on the server, not the client, so every device | |
| 741 | * sees the same machine. A kind picked on a newer server decodes as null | |
| 742 | * here rather than failing the whole settings snapshot for an older client. | |
| 743 | */ | |
| 744 | environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( | |
| 745 | Schema.withDecodingDefault(Effect.succeed(null)), | |
| 746 | ), | |
| 737 | 747 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 738 | 748 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 739 | 749 | ), |
| ... | ... | @@ -945,6 +955,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 945 | 955 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 946 | 956 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 947 | 957 | backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), |
| 958 | environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), | |
| 948 | 959 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 949 | 960 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 950 | 961 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |