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