Unified · Split
| @@ -22,6 +22,7 @@ import IconBox from "@tabler/icons-react-native/IconBox"; · 21 unmodified lines | ||
| 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"; · 4 unmodified lines | ||
| 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>> = { · 72 unmodified lines | ||
| 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>> = { · 13 unmodified lines | ||
| 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, |
| @@ -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 | +} | |
| @@ -4,7 +4,12 @@ import { · 3 unmodified lines | ||
| 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 { · 4 unmodified lines | ||
| 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: { · 180 unmodified lines | ||
| 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: { · 2 unmodified lines | ||
| 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: { · 49 unmodified lines | ||
| 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: { · 49 unmodified lines | ||
| 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} |
| @@ -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"; · 1 unmodified line | ||
| 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: { · 17 unmodified lines | ||
| 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: { · 29 unmodified lines | ||
| 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> |
| @@ -12,10 +12,11 @@ import { · 11 unmodified lines | ||
| 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) { · 590 unmodified lines | ||
| 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) { · 122 unmodified lines | ||
| 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) { · 51 unmodified lines | ||
| 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) { · 44 unmodified lines | ||
| 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) { · 85 unmodified lines | ||
| 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) { · 8 unmodified lines | ||
| 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) { · 32 unmodified lines | ||
| 990 | 1008 | handleSwipeableClose, |
| 991 | 1009 | handleSwipeableWillOpen, |
| 992 | 1010 | handleRegenerateThreadTitle, |
| 1011 | + machineByEnvironmentId, | |
| 993 | 1012 | projectCwdByKey, |
| 994 | 1013 | props.onArchiveThread, |
| 995 | 1014 | props.onDeletePendingTask, |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 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( · 425 unmodified lines | ||
| 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( · 372 unmodified lines | ||
| 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( · 28 unmodified lines | ||
| 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( · 97 unmodified lines | ||
| 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( · 8 unmodified lines | ||
| 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( · 41 unmodified lines | ||
| 1017 | 1035 | handleSelectThread, |
| 1018 | 1036 | handleSwipeableClose, |
| 1019 | 1037 | handleSwipeableWillOpen, |
| 1038 | + machineByEnvironmentId, | |
| 1020 | 1039 | movePinnedThread, |
| 1021 | 1040 | openPendingTask, |
| 1022 | 1041 | pinReorderEnvironmentIds, |
| @@ -4,6 +4,7 @@ import type { · 3 unmodified lines | ||
| 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"; · 4 unmodified lines | ||
| 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: { · 248 unmodified lines | ||
| 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: { · 31 unmodified lines | ||
| 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: { · 105 unmodified lines | ||
| 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: { · 102 unmodified lines | ||
| 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", |
| @@ -3,6 +3,7 @@ import type { · 2 unmodified lines | ||
| 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 · 4 unmodified lines | ||
| 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 · 182 unmodified lines | ||
| 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 · 42 unmodified lines | ||
| 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: { · 61 unmodified lines | ||
| 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: { · 434 unmodified lines | ||
| 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} |
| @@ -20,6 +20,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; · 19 unmodified lines | ||
| 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* () { · 162 unmodified lines | ||
| 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* () { · 12 unmodified lines | ||
| 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* () { · 10 unmodified lines | ||
| 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 | ? { |
| @@ -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 | +}); | |
| @@ -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 | +); | |
| @@ -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 { · 6 unmodified lines | ||
| 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"]); |
| @@ -2,16 +2,15 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ · 1 unmodified line | ||
| 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({ · 87 unmodified lines | ||
| 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({ · 34 unmodified lines | ||
| 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 /> |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | ||
| 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"; · 275 unmodified lines | ||
| 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) { · 1752 unmodified lines | ||
| 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 |
| @@ -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 | +} | |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | ||
| 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 { · 20 unmodified lines | ||
| 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 · 341 unmodified lines | ||
| 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 · 472 unmodified lines | ||
| 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> |
| @@ -29,7 +29,12 @@ import { · 28 unmodified lines | ||
| 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 { · 11 unmodified lines | ||
| 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 { · 67 unmodified lines | ||
| 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({ · 147 unmodified lines | ||
| 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({ · 7 unmodified lines | ||
| 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({ · 32 unmodified lines | ||
| 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: { · 403 unmodified lines | ||
| 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: { · 222 unmodified lines | ||
| 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: { · 2 unmodified lines | ||
| 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: { · 611 unmodified lines | ||
| 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: { · 39 unmodified lines | ||
| 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: { · 89 unmodified lines | ||
| 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() { · 134 unmodified lines | ||
| 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() { · 1840 unmodified lines | ||
| 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() { · 92 unmodified lines | ||
| 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 | } |
| @@ -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 { · 22 unmodified lines | ||
| 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 { · 34 unmodified lines | ||
| 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({ · 189 unmodified lines | ||
| 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({ · 450 unmodified lines | ||
| 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], |
| @@ -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 { · 64 unmodified lines | ||
| 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 { · 10 unmodified lines | ||
| 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( · 10 unmodified lines | ||
| 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( · 5 unmodified lines | ||
| 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 | ]; |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | ||
| 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 { · 20 unmodified lines | ||
| 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"; · 27 unmodified lines | ||
| 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({ · 1346 unmodified lines | ||
| 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({ · 1 unmodified line | ||
| 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() { · 1630 unmodified lines | ||
| 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()} |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -406,6 +406,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 405 unmodified lines | ||
| 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", |
| @@ -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 { · 13 unmodified lines | ||
| 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 { · 59 unmodified lines | ||
| 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() { · 1644 unmodified lines | ||
| 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 = ( |
| @@ -224,6 +224,22 @@ right action without making the transport responsible for process management. Th · 223 unmodified lines | ||
| 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: · 7 unmodified lines | ||
| 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 | |
| @@ -53,6 +53,21 @@ The main sidebar, right panel, and terminal drawer open and close immediately by · 52 unmodified lines | ||
| 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 |
| @@ -43,6 +43,45 @@ export type IsoDateTime = typeof IsoDateTime.Type; · 42 unmodified lines | ||
| 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( |
| @@ -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 · 6 unmodified lines | ||
| 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({ · 79 unmodified lines | ||
| 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 |
| @@ -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", () => { · 142 unmodified lines | ||
| 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 | +}); | |
| @@ -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({ · 565 unmodified lines | ||
| 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, |
| @@ -505,3 +505,22 @@ describe("ServerSettingsPatch string normalization", () => { · 504 unmodified lines | ||
| 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 | +}); | |
| @@ -2,8 +2,8 @@ import * as Effect from "effect/Effect"; · 1 unmodified line | ||
| 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({ · 724 unmodified lines | ||
| 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({ · 205 unmodified lines | ||
| 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), |