authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 19:06:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 19:06:14-07:00
log0fbb94248581aaefda36e4f8a40ec2c6455c779a
treee6031f5546e928c3879ee9203d403da1d8ad42a6
parentd897641d738c67dd7c12cbb3a273b43fe17a5eb1
signature Signed by PGP key B5690EEEBB952194

feat(environments): draw each environment as the machine it runs on (#9299)

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";
22import IconCamera from "@tabler/icons-react-native/IconCamera";22import IconCamera from "@tabler/icons-react-native/IconCamera";
23import IconChartBar from "@tabler/icons-react-native/IconChartBar";23import IconChartBar from "@tabler/icons-react-native/IconChartBar";
24import IconCheck from "@tabler/icons-react-native/IconCheck";24import IconCheck from "@tabler/icons-react-native/IconCheck";
25import IconCloud from "@tabler/icons-react-native/IconCloud";
25import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";26import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";
26import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";27import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";
27import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";28import 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";
32import IconCode from "@tabler/icons-react-native/IconCode";33import IconCode from "@tabler/icons-react-native/IconCode";
33import IconCopy from "@tabler/icons-react-native/IconCopy";34import IconCopy from "@tabler/icons-react-native/IconCopy";
34import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";35import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
36import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
35import IconDots from "@tabler/icons-react-native/IconDots";37import IconDots from "@tabler/icons-react-native/IconDots";
36import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";38import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
37import IconEdit from "@tabler/icons-react-native/IconEdit";39import 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 @@
1import type { EnvironmentMachineKind } from "@t3tools/contracts";
2import type { SFSymbol } from "expo-symbols";
3
4import { SymbolView } from "./AppSymbol";
5
6const 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
15export 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. */
25export 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";
7import type { EnvironmentId } from "@t3tools/contracts";7import {
8 type EnvironmentId,
9 type EnvironmentMachineKind,
10 resolveEnvironmentMachineKind,
11} from "@t3tools/contracts";
12import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useState } from "react";13import { useCallback, useState } from "react";
9import {14import {
10 ActivityIndicator,15 ActivityIndicator,
...@@ -15,10 +20,12 @@ import {...@@ -15,10 +20,12 @@ import {
15} from "react-native";20} from "react-native";
1621
17import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
23import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
18import { ThemedSwitch } from "../../components/ThemedSwitch";24import { ThemedSwitch } from "../../components/ThemedSwitch";
19import { cn } from "../../lib/cn";25import { cn } from "../../lib/cn";
20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";27import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
28import { serverEnvironment } from "../../state/server";
22import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";29import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
23import { hasCloudPublicConfig } from "../cloud/publicConfig";30import { hasCloudPublicConfig } from "../cloud/publicConfig";
24import { ConnectionStatusDot } from "./ConnectionStatusDot";31import { 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 <CloudEnvironmentRowShell219 <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 <Text346 <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 @@
1import { SymbolView } from "../../components/AppSymbol";1import { SymbolView } from "../../components/AppSymbol";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";2import { connectionStatusText } from "@t3tools/client-runtime/connection";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import type { EnvironmentId } from "@t3tools/contracts";4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
5import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
6import { AsyncResult } from "effect/unstable/reactivity";7import { AsyncResult } from "effect/unstable/reactivity";
7import { useCallback, useState } from "react";8import { 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";
9import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";10import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1011
11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";12import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
12import { cn } from "../../lib/cn";14import { cn } from "../../lib/cn";
13import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
14import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
17import { serverEnvironment } from "../../state/server";
15import { ConnectionStatusDot } from "./ConnectionStatusDot";18import { ConnectionStatusDot } from "./ConnectionStatusDot";
1619
17function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {20function 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 />
7177
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";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type {15import {
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";
20import { useAtomSet, useAtomValue } from "@effect/atom-react";21import { useAtomSet, useAtomValue } from "@effect/atom-react";
21import { AsyncResult } from "effect/unstable/reactivity";22import { 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 the626 // Canonical arranged pinned order (reorder-capable threads only) for the
616 // Move up/down position flags. Computed from all shells, not the rendered627 // 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 : null752 : 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 : null810 : 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 ?? null953 ?.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 ?? null970 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 null975 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 {
9import { LegendList } from "@legendapp/list/react-native";9import { LegendList } from "@legendapp/list/react-native";
10import type { MenuAction } from "@react-native-menu/menu";10import type { MenuAction } from "@react-native-menu/menu";
11import { useAtomValue } from "@effect/atom-react";11import { useAtomValue } from "@effect/atom-react";
12import type { EnvironmentId } from "@t3tools/contracts";12import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
13import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";13import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
14import { useCallback, useEffect, useMemo, useRef, useState } from "react";14import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15import type { LayoutChangeEvent } from "react-native";15import 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 from454 // 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 : null830 : 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 : null867 : 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 null971 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 ?? null988 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 null993 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";
6import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";6import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
7import type { EnvironmentMachineKind } from "@t3tools/contracts";
7import type { MenuAction } from "@react-native-menu/menu";8import type { MenuAction } from "@react-native-menu/menu";
8import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
9import { memo, useCallback, useMemo, type ComponentProps } from "react";10import { 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";
1415
15import { AppText as Text } from "../../components/AppText";16import { AppText as Text } from "../../components/AppText";
16import { ControlPillMenu } from "../../components/ControlPill";17import { ControlPillMenu } from "../../components/ControlPill";
18import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
17import { ProjectFavicon } from "../../components/ProjectFavicon";19import { ProjectFavicon } from "../../components/ProjectFavicon";
18import { cn } from "../../lib/cn";20import { cn } from "../../lib/cn";
19import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";21import { 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 <Text318 <Text
309 className={319 className={
310 compact320 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 <Text551 <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";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import type { EnvironmentMachineKind } from "@t3tools/contracts";
6import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";7import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
7import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";8import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
8import type { MenuAction } from "@react-native-menu/menu";9import 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
13import { SymbolView } from "../../components/AppSymbol";14import { SymbolView } from "../../components/AppSymbol";
14import { AppText as Text } from "../../components/AppText";15import { AppText as Text } from "../../components/AppText";
15import { ControlPillMenu } from "../../components/ControlPill";16import { ControlPillMenu } from "../../components/ControlPill";
17import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
16import { ProjectFavicon } from "../../components/ProjectFavicon";18import { ProjectFavicon } from "../../components/ProjectFavicon";
17import { ProviderIcon } from "../../components/ProviderIcon";19import { ProviderIcon } from "../../components/ProviderIcon";
18import { cn } from "../../lib/cn";20import { 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 since340 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 blending348 "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 <Text796 <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";
20import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
21import * as ProcessRunner from "../processRunner.ts";21import * as ProcessRunner from "../processRunner.ts";
22import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";22import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
23import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts";
2324
24export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(25export 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" || desktopAppUpdate230 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
227 ? {231 ? {
apps/server/src/environment/ServerEnvironmentMachine.test.ts created+225
...@@ -0,0 +1,225 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";
6import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { vi } from "vite-plus/test";
9
10import * as ProcessRunner from "../processRunner.ts";
11import {
12 detectServerEnvironmentMachineKind,
13 machineKindFromAppleProductName,
14 machineKindFromDmi,
15} from "./ServerEnvironmentMachine.ts";
16
17const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
18
19const ProcessRunnerTest = Layer.succeed(
20 ProcessRunner.ProcessRunner,
21 ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }),
22);
23
24const 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
36const 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
54const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) =>
55 Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform));
56
57afterEach(() => {
58 runMock.mockReset();
59});
60
61describe("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
77describe("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
127describe("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 @@
1import type { EnvironmentMachineKind } from "@t3tools/contracts";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5
6import * 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
14const 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.
19const 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.
51const 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
72function 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. */
78export 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
87export 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
105const 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
113const 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.
134const 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
146const 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
155export 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 @@
1import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";1import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import { toSortableTimestamp } from "../lib/threadSort";3import { toSortableTimestamp } from "../lib/threadSort";
4export {4export {
...@@ -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}
1516
16export const EnvMode = Schema.Literals(["local", "worktree"]);17export 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
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import {3import {
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";
12import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";10import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1311
14import { useComposerDraftStore, type DraftId } from "../composerDraftStore";12import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
13import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
15import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";14import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
16import { useIsMobile } from "../hooks/useMediaQuery";15import { useIsMobile } from "../hooks/useMediaQuery";
17import {16import {
...@@ -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 4px108 // 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 <MenuRadioItem158 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";
303import { NoActiveThreadState } from "./NoActiveThreadState";304import { NoActiveThreadState } from "./NoActiveThreadState";
304import { WorkspacePageHeader } from "./WorkspacePageHeader";305import { WorkspacePageHeader } from "./WorkspacePageHeader";
305import {306import {
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 alphabetical2080 // Sort: primary first, then alphabetical
apps/web/src/components/EnvironmentMachineIcon.tsx created+75
...@@ -0,0 +1,75 @@
1import type { EnvironmentMachineKind } from "@t3tools/contracts";
2import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react";
3import 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.
8function 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. */
26export 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. */
36export 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
45const 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
54export 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
63export function environmentMachineIcon(
64 kind: EnvironmentMachineKind,
65): FunctionComponent<LucideProps> {
66 return ICON_BY_KIND[kind];
67}
68
69export 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";
25import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
25import { ProjectFavicon } from "./ProjectFavicon";26import { ProjectFavicon } from "./ProjectFavicon";
26import { useAtomValue } from "@effect/atom-react";27import { useAtomValue } from "@effect/atom-react";
27import { autoAnimate } from "@formkit/auto-animate";28import { 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";
53import {55import {
...@@ -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 a405 // 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 the406 // 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" and407 // 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";
32import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";32import {
33 resolveEnvironmentMachineKind,
34 type EnvironmentMachineKind,
35 type ScopedThreadRef,
36 type ThreadId,
37} from "@t3tools/contracts";
33import type { TimestampFormat } from "@t3tools/contracts/settings";38import type { TimestampFormat } from "@t3tools/contracts/settings";
34import {39import {
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 {
121import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";125import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
122import type { SidebarThreadSummary } from "../types";126import type { SidebarThreadSummary } from "../types";
123import { cn } from "~/lib/utils";127import { cn } from "~/lib/utils";
128import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
124import { buildThreadActionMenuItems } from "./threadActionMenu.logic";129import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
125import {130import {
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;
965976
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;
968982
969 const detailsTooltip = (983 const detailsTooltip = (
970 <SidebarThreadTooltip984 <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 ) ?? null3751 ) ?? 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_ENTRIES3759 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}`) ?? null3859 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 @@
1import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type {3import {
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";
12import {13import {
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";
42import {42import {
...@@ -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";
80import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
80import { Badge } from "../ui/badge";81import { Badge } from "../ui/badge";
81import { Button } from "../ui/button";82import { Button } from "../ui/button";
82import { Input } from "../ui/input";83import { 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 label273 {/* 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 @@
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";1import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts";
22
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. */
4export interface AssignableProject {4export 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}
7374
74/**75/**
...@@ -85,17 +86,21 @@ export interface PickableEnvironment {...@@ -85,17 +86,21 @@ export interface PickableEnvironment {
85export function resolvePickableEnvironments(86export 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 the106 // 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";
31import { connectionStatusText } from "@t3tools/client-runtime/connection";32import { connectionStatusText } from "@t3tools/client-runtime/connection";
32import {33import {
...@@ -53,6 +54,7 @@ import {...@@ -53,6 +54,7 @@ import {
53 useRelativeTimeTick,54 useRelativeTimeTick,
54} from "./settingsLayout";55} from "./settingsLayout";
55import { searchableSetting } from "./settingsSearch";56import { searchableSetting } from "./settingsSearch";
57import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
56import { Input } from "../ui/input";58import { Input } from "../ui/input";
57import { Checkbox } from "../ui/checkbox";59import { Checkbox } from "../ui/checkbox";
58import {60import {
...@@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
86import { Button } from "../ui/button";88import { Button } from "../ui/button";
87import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";89import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
88import { AnimatedHeight } from "../AnimatedHeight";90import { AnimatedHeight } from "../AnimatedHeight";
91import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
89import { Textarea } from "../ui/textarea";92import { Textarea } from "../ui/textarea";
90import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";93import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
91import { readHostedPairingRequest } from "../../hostedPairing";94import { readHostedPairingRequest } from "../../hostedPairing";
...@@ -1438,6 +1441,11 @@ function SavedBackendListRow({...@@ -1438,6 +1441,11 @@ function SavedBackendListRow({
1438 : null1441 : 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 @@
1import type { ServerConfig } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveEnvironmentIconPickerLock } from "./EnvironmentIconPicker";
5
6const config = (environmentIcon: boolean | undefined) =>
7 ({
8 environment: { capabilities: environmentIcon === undefined ? {} : { environmentIcon } },
9 }) as unknown as ServerConfig;
10
11describe("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 @@
1import {
2 ENVIRONMENT_MACHINE_KINDS,
3 isEnvironmentMachineKind,
4 resolveEnvironmentMachineKind,
5 type EnvironmentId,
6 type ServerConfig,
7} from "@t3tools/contracts";
8import { useCallback } from "react";
9
10import { isElectron } from "../../env";
11import { usePrimarySessionState } from "../../environments/primary";
12import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
13import { usePrimaryEnvironmentId } from "../../state/environments";
14import { useEnvironmentSessionState } from "../../state/session";
15import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
18import {
19 resolvePrimaryOperateAccess,
20 resolveRemoteOperateAccess,
21} from "./ProviderSettingsPanel.logic";
22
23const 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 */
29export 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.
48function 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 */
78export 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 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { pullRequestHostOf, ThreadId } from "@t3tools/contracts";2import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
3import type {3import 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";
88import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";86import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
87import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
89import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";88import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
90import {89import {
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 local1738 // 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
224supervisor owns the resulting disconnect and reconnect like any other involuntary close. See224supervisor 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).
226226
227## Machine identity
228
229Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in
230contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the
231precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift:
232
2331. `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.
2352. `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.
2393. A generic server otherwise.
240
241Clients treat the field like any other capability: absent means "use the fallback", never "wait".
242
227## Future work243## Future work
228244
229These remain unbuilt and are listed to keep the model honest:245These 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.ts253[authremote]: ../../packages/client-runtime/src/authorization/remote.ts
238[sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts254[sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts
239[sshtunnel]: ../../packages/ssh/src/tunnel.ts255[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
53The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at53The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at
540 ms, it snaps between the same open and closed states.540 ms, it snaps between the same open and closed states.
5555
56## Environment icons
57
58When you are connected to more than one environment, every thread that lives somewhere other than
59the machine you are on wears a small icon for that machine at the end of its row: a server, a cloud
60VM, a desktop, a laptop, a Mac mini, or a Mac Studio. In the hosted web app and the mobile app,
61where every environment is remote, each row wears its machine so you can tell them apart at a
62glance. The same icon appears in the thread tooltip, the "Run on" picker, the pull request server
63filter, and the environment lists under **Settings → Connections**.
64
65Servers pick the icon themselves from the hardware they run on. A Mac reports its model, a Linux
66machine reports its chassis type and whether it is a virtual machine, and anything without a usable
67signal shows a generic server. To override it, open **Settings → Connections** and choose an icon
68for that environment; **Automatic** goes back to what the server detected. The choice is stored on
69that server, so every device that connects to it sees the same icon.
70
56## Environment artwork71## Environment artwork
5772
58Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in73Dev 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 client43 * 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 */
51export 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 */
72export 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
46export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {85export 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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";4import {
5 EnvironmentId,
6 ForwardCompatibleOptional,
7 ProjectId,
8 ThreadId,
9 TrimmedNonEmptyString,
10} from "./baseSchemas.ts";
511
6export const ExecutionEnvironmentPlatformOs = Schema.Literals([12export 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
14export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]);20export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]);
15export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type;21export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type;
1622
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 */
28export const ENVIRONMENT_MACHINE_KINDS = [
29 "server",
30 "cloud",
31 "desktop",
32 "laptop",
33 "mac-mini",
34 "mac-studio",
35] as const;
36export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS);
37export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type;
38export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind);
39
17export const ExecutionEnvironmentPlatform = Schema.Struct({40export 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});
2148
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 older129 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 on137 server.updateServer runs its check -> download -> relaunch. Absent on
107 desktop servers whose app predates the remote trigger, where clients138 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 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { ExecutionEnvironmentDescriptor } from "./environment.ts";
4import {5import {
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";
12import { ServerSettings } from "./settings.ts";
1013
11const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);14const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
12const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);15const 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
162describe("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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";3import {
4 type EnvironmentMachineKind,
5 ExecutionEnvironmentDescriptor,
6 ServerSelfUpdateMethod,
7} from "./environment.ts";
4import { ServerAuthDescriptor } from "./auth.ts";8import { ServerAuthDescriptor } from "./auth.ts";
5import {9import {
6 ForwardCompatibleArray,10 ForwardCompatibleArray,
...@@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({...@@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({
572});576});
573export type ServerConfig = typeof ServerConfig.Type;577export type ServerConfig = typeof ServerConfig.Type;
574578
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 */
585export function resolveEnvironmentMachineKind(
586 config: Pick<ServerConfig, "environment" | "settings"> | null,
587): EnvironmentMachineKind {
588 return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server";
589}
590
575const ServerUpsertKeybindingReplaceTarget = Schema.Struct({591const 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
509describe("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";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { ThreadEnvMode } from "./environment.ts";6import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
7import {7import {
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),