History

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

Julius Marminge committed 2 months ago

Commit 0fbb9421 parent d89764132 files · +1222 −84Browse files

Unified · Split

32 files
  1. apps/mobile/src/components/AppSymbol.tsx +7 −0
  2. apps/mobile/src/components/EnvironmentMachineSymbol.tsx +39 −0
  3. apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +21 −1
  4. apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +20 −4
  5. apps/mobile/src/features/home/HomeScreen.tsx +23 −4
  6. apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +20 −1
  7. apps/mobile/src/features/threads/thread-list-items.tsx +24 −0
  8. apps/mobile/src/features/threads/thread-list-v2-items.tsx +35 −10
  9. apps/server/src/environment/ServerEnvironment.ts +4 −0
  10. apps/server/src/environment/ServerEnvironmentMachine.test.ts +225 −0
  11. apps/server/src/environment/ServerEnvironmentMachine.ts +167 −0
  12. apps/web/src/components/BranchToolbar.logic.ts +2 −1
  13. apps/web/src/components/BranchToolbar.tsx +17 −19
  14. apps/web/src/components/ChatView.tsx +6 −8
  15. apps/web/src/components/EnvironmentMachineIcon.tsx +75 −0
  16. apps/web/src/components/LegacySidebar.tsx +10 −3
  17. apps/web/src/components/Sidebar.tsx +46 −6
  18. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +19 −11
  19. apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts +13 −6
  20. apps/web/src/components/settings/ConnectionsSettings.tsx +29 −0
  21. apps/web/src/components/settings/EnvironmentIconPicker.test.ts +41 −0
  22. apps/web/src/components/settings/EnvironmentIconPicker.tsx +161 −0
  23. apps/web/src/components/settings/settingsSearch.ts +8 −0
  24. apps/web/src/routes/_chat.pull-requests.tsx +5 −6
  25. docs/internals/remote.md +17 −0
  26. docs/user/thread-sidebar.md +15 −0
  27. packages/contracts/src/baseSchemas.ts +39 −0
  28. packages/contracts/src/environment.ts +32 −1
  29. packages/contracts/src/server.test.ts +53 −0
  30. packages/contracts/src/server.ts +17 −1
  31. packages/contracts/src/settings.test.ts +19 −0
  32. packages/contracts/src/settings.ts +13 −2
apps/mobile/src/components/AppSymbol.tsx+7−0
@@ -22,6 +22,7 @@ import IconBox from "@tabler/icons-react-native/IconBox"; · 21 unmodified lines
2222 import IconCamera from "@tabler/icons-react-native/IconCamera";
2323 import IconChartBar from "@tabler/icons-react-native/IconChartBar";
2424 import IconCheck from "@tabler/icons-react-native/IconCheck";
25+import IconCloud from "@tabler/icons-react-native/IconCloud";
2526 import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";
2627 import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";
2728 import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
@@ -32,6 +33,7 @@ import IconClock from "@tabler/icons-react-native/IconClock"; · 4 unmodified lines
3233 import IconCode from "@tabler/icons-react-native/IconCode";
3334 import IconCopy from "@tabler/icons-react-native/IconCopy";
3435 import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
36+import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
3537 import IconDots from "@tabler/icons-react-native/IconDots";
3638 import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
3739 import IconEdit from "@tabler/icons-react-native/IconEdit";
@@ -110,6 +112,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 72 unmodified lines
110112 checkmark: IconCheck,
111113 "checkmark.circle": IconCircleCheck,
112114 clock: IconClock,
115+ cloud: IconCloud,
113116 cube: IconBox,
114117 "chevron.down": IconChevronDown,
115118 "chevron.left": IconChevronLeft,
@@ -129,9 +132,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { · 13 unmodified lines
129132 "folder.fill": IconFolder,
130133 gearshape: IconSettings,
131134 "info.circle": IconInfoCircle,
135+ laptopcomputer: IconDeviceLaptop,
132136 link: IconLink,
133137 "line.3.horizontal.decrease.circle": IconFilter,
134138 "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,
135142 magnifyingglass: IconSearch,
136143 paintbrush: IconPalette,
137144 "person.crop.circle": IconUserCircle,
apps/mobile/src/components/EnvironmentMachineSymbol.tsx+39−0
@@ -0,0 +1,39 @@
1+import type { EnvironmentMachineKind } from "@t3tools/contracts";
2+import type { SFSymbol } from "expo-symbols";
3+
4+import { SymbolView } from "./AppSymbol";
5+
6+const SYMBOL_BY_KIND: Record<EnvironmentMachineKind, SFSymbol> = {
7+ server: "server.rack",
8+ cloud: "cloud",
9+ desktop: "desktopcomputer",
10+ laptop: "laptopcomputer",
11+ "mac-mini": "macmini",
12+ "mac-studio": "macstudio",
13+};
14+
15+export const ENVIRONMENT_MACHINE_KIND_LABELS: Record<EnvironmentMachineKind, string> = {
16+ server: "Server",
17+ cloud: "Cloud VM",
18+ desktop: "Desktop",
19+ laptop: "Laptop",
20+ "mac-mini": "Mac mini",
21+ "mac-studio": "Mac Studio",
22+};
23+
24+/** The glyph an environment wears in lists; SF Symbols on iOS, Tabler on Android. */
25+export function EnvironmentMachineSymbol(props: {
26+ readonly kind: EnvironmentMachineKind;
27+ readonly size: number;
28+ readonly tintColorClassName: string;
29+}) {
30+ return (
31+ <SymbolView
32+ accessibilityLabel={ENVIRONMENT_MACHINE_KIND_LABELS[props.kind]}
33+ name={SYMBOL_BY_KIND[props.kind]}
34+ size={props.size}
35+ tintColorClassName={props.tintColorClassName}
36+ type="monochrome"
37+ />
38+ );
39+}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+21−1
@@ -4,7 +4,12 @@ import { · 3 unmodified lines
44 connectionStatusText,
55 type EnvironmentConnectionPhase,
66 } from "@t3tools/client-runtime/connection";
7−import type { EnvironmentId } from "@t3tools/contracts";
7+import {
8+ type EnvironmentId,
9+ type EnvironmentMachineKind,
10+ resolveEnvironmentMachineKind,
11+} from "@t3tools/contracts";
12+import { useAtomValue } from "@effect/atom-react";
813 import { useCallback, useState } from "react";
914 import {
1015 ActivityIndicator,
@@ -15,10 +20,12 @@ import { · 4 unmodified lines
1520 } from "react-native";
1621
1722 import { AppText as Text } from "../../components/AppText";
23+import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1824 import { ThemedSwitch } from "../../components/ThemedSwitch";
1925 import { cn } from "../../lib/cn";
2026 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
2127 import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
28+import { serverEnvironment } from "../../state/server";
2229 import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
2330 import { hasCloudPublicConfig } from "../cloud/publicConfig";
2431 import { ConnectionStatusDot } from "./ConnectionStatusDot";
@@ -205,6 +212,9 @@ function ConnectedCloudEnvironmentRow(props: { · 180 unmodified lines
205212 readonly onDisconnect: () => void;
206213 readonly onToggleError: () => void;
207214 }) {
215+ const serverConfig = useAtomValue(
216+ serverEnvironment.configValueAtom(props.environment.environmentId),
217+ );
208218 return (
209219 <CloudEnvironmentRowShell
210220 borderTop={props.borderTop}
@@ -213,6 +223,7 @@ function ConnectedCloudEnvironmentRow(props: { · 2 unmodified lines
213223 connectionState={props.environment.connectionState}
214224 errorExpanded={props.errorExpanded}
215225 label={props.environment.environmentLabel}
226+ machine={resolveEnvironmentMachineKind(serverConfig)}
216227 onValueChange={(enabled) => {
217228 if (enabled) {
218229 props.onConnect();
@@ -268,6 +279,8 @@ function CloudEnvironmentRowShell(props: { · 49 unmodified lines
268279 readonly disabled?: boolean;
269280 readonly errorExpanded: boolean;
270281 readonly label: string;
282+ /** Absent for environments the relay lists but this device has not connected to. */
283+ readonly machine?: EnvironmentMachineKind;
271284 readonly onToggleError: () => void;
272285 readonly onValueChange: (enabled: boolean) => void;
273286 readonly statusText?: string;
@@ -323,6 +336,13 @@ function CloudEnvironmentRowShell(props: { · 49 unmodified lines
323336 <View className="min-w-0 flex-1 gap-0.5">
324337 <View className="min-w-0 flex-row items-center gap-2">
325338 <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}
326346 <Text
327347 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
328348 numberOfLines={1}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+20−4
@@ -1,7 +1,8 @@
11 import { SymbolView } from "../../components/AppSymbol";
22 import { connectionStatusText } from "@t3tools/client-runtime/connection";
33 import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4−import type { EnvironmentId } from "@t3tools/contracts";
4+import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5+import { useAtomValue } from "@effect/atom-react";
56 import * as Cause from "effect/Cause";
67 import { AsyncResult } from "effect/unstable/reactivity";
78 import { useCallback, useState } from "react";
@@ -9,9 +10,11 @@ import { Alert, Pressable, View } from "react-native"; · 1 unmodified line
910 import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1011
1112 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13+import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1214 import { cn } from "../../lib/cn";
1315 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1416 import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
17+import { serverEnvironment } from "../../state/server";
1518 import { ConnectionStatusDot } from "./ConnectionStatusDot";
1619
1720 function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
@@ -35,6 +38,9 @@ export function ConnectionEnvironmentRow(props: { · 17 unmodified lines
3538 }) {
3639 const [label, setLabel] = useState(props.environment.environmentLabel);
3740 const [url, setUrl] = useState(props.environment.displayUrl);
41+ const serverConfig = useAtomValue(
42+ serverEnvironment.configValueAtom(props.environment.environmentId),
43+ );
3844 const statusLabel = connectionStatusLabel(props.environment);
3945 const statusTraceId = props.environment.connectionErrorTraceId;
4046 const hasConnectionFailure = props.environment.connectionError !== null;
@@ -70,9 +76,19 @@ export function ConnectionEnvironmentRow(props: { · 29 unmodified lines
7076 />
7177
7278 <View className="flex-1 gap-0.5">
73− <Text className="text-base font-t3-bold leading-snug text-foreground" numberOfLines={1}>
74− {props.environment.environmentLabel}
75− </Text>
79+ <View className="flex-row items-center gap-1.5">
80+ <EnvironmentMachineSymbol
81+ kind={resolveEnvironmentMachineKind(serverConfig)}
82+ size={14}
83+ tintColorClassName="accent-foreground-muted"
84+ />
85+ <Text
86+ className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
87+ numberOfLines={1}
88+ >
89+ {props.environment.environmentLabel}
90+ </Text>
91+ </View>
7692 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
7793 {props.environment.displayUrl}
7894 </Text>
apps/mobile/src/features/home/HomeScreen.tsx+23−4
@@ -12,10 +12,11 @@ import { · 11 unmodified lines
1212 type EnvironmentThreadSearchMatch,
1313 } from "@t3tools/client-runtime/state/thread-search";
1414 import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15−import type {
16− EnvironmentId,
17− SidebarProjectGroupingMode,
18− SidebarThreadSortOrder,
15+import {
16+ type EnvironmentId,
17+ resolveEnvironmentMachineKind,
18+ type SidebarProjectGroupingMode,
19+ type SidebarThreadSortOrder,
1920 } from "@t3tools/contracts";
2021 import { useAtomSet, useAtomValue } from "@effect/atom-react";
2122 import { AsyncResult } from "effect/unstable/reactivity";
@@ -612,6 +613,16 @@ export function HomeScreen(props: HomeScreenProps) { · 590 unmodified lines
612613 }
613614 return supported;
614615 }, [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+ );
615626 // Canonical arranged pinned order (reorder-capable threads only) for the
616627 // Move up/down position flags. Computed from all shells, not the rendered
617628 // list, so search/scope filtering never disables or misdirects a move.
@@ -740,6 +751,7 @@ export function HomeScreen(props: HomeScreenProps) { · 122 unmodified lines
740751 ?.environmentLabel ?? null)
741752 : null
742753 }
754+ environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)}
743755 showPendingDivider={item.showPendingDivider}
744756 showTrailingDivider={showTrailingDivider}
745757 onSelectPendingTask={props.onSelectPendingTask}
@@ -797,6 +809,7 @@ export function HomeScreen(props: HomeScreenProps) { · 51 unmodified lines
797809 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
798810 : null
799811 }
812+ environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
800813 searchMatch={threadSearchMatchByKey.get(
801814 threadSearchMatchKey({
802815 environmentId: thread.environmentId,
@@ -847,6 +860,7 @@ export function HomeScreen(props: HomeScreenProps) { · 44 unmodified lines
847860 handleSwipeableWillOpen,
848861 handleUnsettleThread,
849862 pinningEnvironmentIds,
863+ machineByEnvironmentId,
850864 pinReorderEnvironmentIds,
851865 projectByKey,
852866 projectCwdByKey,
@@ -938,6 +952,9 @@ export function HomeScreen(props: HomeScreenProps) { · 85 unmodified lines
938952 props.savedConnectionsById[item.pendingTask.message.environmentId]
939953 ?.environmentLabel ?? null
940954 }
955+ environmentMachine={machineByEnvironmentId.get(
956+ item.pendingTask.message.environmentId,
957+ )}
941958 isLast={item.isLast}
942959 onSelectPendingTask={props.onSelectPendingTask}
943960 onDeletePendingTask={props.onDeletePendingTask}
@@ -952,6 +969,7 @@ export function HomeScreen(props: HomeScreenProps) { · 8 unmodified lines
952969 environmentLabel={
953970 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
954971 }
972+ environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
955973 projectCwd={
956974 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
957975 null
@@ -990,6 +1008,7 @@ export function HomeScreen(props: HomeScreenProps) { · 32 unmodified lines
9901008 handleSwipeableClose,
9911009 handleSwipeableWillOpen,
9921010 handleRegenerateThreadTitle,
1011+ machineByEnvironmentId,
9931012 projectCwdByKey,
9941013 props.onArchiveThread,
9951014 props.onDeletePendingTask,
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+20−1
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
99 import { LegendList } from "@legendapp/list/react-native";
1010 import type { MenuAction } from "@react-native-menu/menu";
1111 import { useAtomValue } from "@effect/atom-react";
12−import type { EnvironmentId } from "@t3tools/contracts";
12+import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
1313 import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1414 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1515 import type { LayoutChangeEvent } from "react-native";
@@ -441,6 +441,16 @@ function ThreadNavigationSidebarPane( · 425 unmodified lines
441441 }
442442 return supported;
443443 }, [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+ );
444454 // Canonical arranged pinned order for Move up/down flags — computed from
445455 // all shells so search/scope filtering never disables a valid move.
446456 const arrangedPinnedKeys = useMemo(() => {
@@ -819,6 +829,9 @@ function ThreadNavigationSidebarPane( · 372 unmodified lines
819829 ?.environmentLabel ?? null)
820830 : null
821831 }
832+ environmentMachine={machineByEnvironmentId.get(
833+ item.pendingTask.message.environmentId,
834+ )}
822835 pane="sidebar"
823836 showPendingDivider={item.showPendingDivider}
824837 onSelectPendingTask={openPendingTask}
@@ -853,6 +866,7 @@ function ThreadNavigationSidebarPane( · 28 unmodified lines
853866 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
854867 : null
855868 }
869+ environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
856870 searchMatch={threadSearchMatchByKey.get(
857871 threadSearchMatchKey({
858872 environmentId: thread.environmentId,
@@ -956,6 +970,9 @@ function ThreadNavigationSidebarPane( · 97 unmodified lines
956970 savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
957971 null
958972 }
973+ environmentMachine={machineByEnvironmentId.get(
974+ item.pendingTask.message.environmentId,
975+ )}
959976 isLast={item.isLast}
960977 onSelectPendingTask={openPendingTask}
961978 onDeletePendingTask={confirmDeletePendingTask}
@@ -970,6 +987,7 @@ function ThreadNavigationSidebarPane( · 8 unmodified lines
970987 environmentLabel={
971988 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
972989 }
990+ environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
973991 projectCwd={
974992 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
975993 null
@@ -1017,6 +1035,7 @@ function ThreadNavigationSidebarPane( · 41 unmodified lines
10171035 handleSelectThread,
10181036 handleSwipeableClose,
10191037 handleSwipeableWillOpen,
1038+ machineByEnvironmentId,
10201039 movePinnedThread,
10211040 openPendingTask,
10221041 pinReorderEnvironmentIds,
apps/mobile/src/features/threads/thread-list-items.tsx+24−0
@@ -4,6 +4,7 @@ import type { · 3 unmodified lines
44 EnvironmentThreadShell,
55 } from "@t3tools/client-runtime/state/shell";
66 import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
7+import type { EnvironmentMachineKind } from "@t3tools/contracts";
78 import type { MenuAction } from "@react-native-menu/menu";
89 import { SymbolView } from "../../components/AppSymbol";
910 import { memo, useCallback, useMemo, type ComponentProps } from "react";
@@ -14,6 +15,7 @@ import Svg, { Circle, Path } from "react-native-svg"; · 4 unmodified lines
1415
1516 import { AppText as Text } from "../../components/AppText";
1617 import { ControlPillMenu } from "../../components/ControlPill";
18+import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1719 import { ProjectFavicon } from "../../components/ProjectFavicon";
1820 import { cn } from "../../lib/cn";
1921 import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
@@ -268,6 +270,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { · 248 unmodified lines
268270 readonly variant: ThreadListVariant;
269271 readonly pendingTask: PendingNewTask;
270272 readonly environmentLabel: string | null;
273+ readonly environmentMachine?: EnvironmentMachineKind;
271274 readonly isLast: boolean;
272275 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
273276 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
@@ -305,6 +308,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { · 31 unmodified lines
305308 tintColorClassName={compact ? "accent-icon-subtle" : "accent-foreground-muted"}
306309 type="monochrome"
307310 />
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}
308318 <Text
309319 className={
310320 compact
@@ -416,6 +426,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 105 unmodified lines
416426 readonly variant: ThreadListVariant;
417427 readonly thread: EnvironmentThreadShell;
418428 readonly environmentLabel: string | null;
429+ readonly environmentMachine?: EnvironmentMachineKind;
419430 readonly projectCwd: string | null;
420431 readonly searchMatch?: EnvironmentThreadSearchMatch;
421432 readonly searchQuery?: string;
@@ -524,6 +535,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { · 102 unmodified lines
524535 <View className="mt-px flex-row items-center gap-1.5">
525536 {subtitleParts.length > 0 ? (
526537 <>
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}
527551 <Text
528552 className={cn(
529553 "shrink",
apps/mobile/src/features/threads/thread-list-v2-items.tsx+35−10
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
33 EnvironmentThreadShell,
44 } from "@t3tools/client-runtime/state/shell";
55 import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6+import type { EnvironmentMachineKind } from "@t3tools/contracts";
67 import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
78 import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
89 import type { MenuAction } from "@react-native-menu/menu";
@@ -13,6 +14,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw · 4 unmodified lines
1314 import { SymbolView } from "../../components/AppSymbol";
1415 import { AppText as Text } from "../../components/AppText";
1516 import { ControlPillMenu } from "../../components/ControlPill";
17+import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1618 import { ProjectFavicon } from "../../components/ProjectFavicon";
1719 import { ProviderIcon } from "../../components/ProviderIcon";
1820 import { cn } from "../../lib/cn";
@@ -201,6 +203,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props · 182 unmodified lines
201203 readonly project: EnvironmentProject | null;
202204 readonly projectTitle?: string;
203205 readonly environmentLabel: string | null;
206+ /** Drawn beside the label; ignored while the label is null. */
207+ readonly environmentMachine?: EnvironmentMachineKind;
204208 readonly pane?: "screen" | "sidebar";
205209 /** Draws the "Pending" divider above the first queued row. */
206210 readonly showPendingDivider: boolean;
@@ -249,17 +253,26 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props · 42 unmodified lines
249253 {pendingTask.title}
250254 </Text>
251255 {branch || props.environmentLabel ? (
252− <Text className="mt-1 text-xs text-foreground-muted" numberOfLines={1}>
253− {branch ? (
254− <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}>
255− {branch}
256− </Text>
257− ) : null}
258− {branch && props.environmentLabel ? " · " : null}
259− {props.environmentLabel ? (
260− <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text>
256+ <View className="mt-1 flex-row items-center gap-1">
257+ <Text className="shrink text-xs text-foreground-muted" numberOfLines={1}>
258+ {branch ? (
259+ <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}>
260+ {branch}
261+ </Text>
262+ ) : null}
263+ {branch && props.environmentLabel ? " · " : null}
264+ {props.environmentLabel ? (
265+ <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text>
266+ ) : null}
267+ </Text>
268+ {props.environmentLabel && props.environmentMachine ? (
269+ <EnvironmentMachineSymbol
270+ kind={props.environmentMachine}
271+ size={11}
272+ tintColorClassName="accent-foreground-tertiary"
273+ />
261274 ) : null}
262− </Text>
275+ </View>
263276 ) : null}
264277 </>
265278 );
@@ -327,6 +340,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 61 unmodified lines
327340 the web sidebar's remote-environment cloud icon, but as text since
328341 phones have no hover tooltips. */
329342 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;
330346 /** Hosting surface. "screen" (default) renders the compact Home idiom:
331347 flat edge-to-edge rows on the screen background with inset hairlines.
332348 "sidebar" renders the iPad split-view idiom: rounded rows blending
@@ -767,6 +783,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 434 unmodified lines
767783 ) : (
768784 <View className="flex-1" />
769785 )}
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}
770795 {pr ? (
771796 <Text
772797 accessibilityLabel={pr.accessibilityLabel}
apps/server/src/environment/ServerEnvironment.ts+4−0
@@ -20,6 +20,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; · 19 unmodified lines
2020 import * as ServerConfig from "../config.ts";
2121 import * as ProcessRunner from "../processRunner.ts";
2222 import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
23+import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts";
2324
2425 export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(
2526 "ServerEnvironmentIdPersistenceError",
@@ -188,6 +189,7 @@ export const make = Effect.gen(function* () { · 162 unmodified lines
188189 const environmentId = yield* identity.getEnvironmentId;
189190 const cwdBaseName = path.basename(serverConfig.cwd).trim();
190191 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
192+ const machine = yield* detectServerEnvironmentMachineKind();
191193 const launcher = yield* resolveServiceLauncherMode();
192194 const serverSelfUpdate = resolveServerSelfUpdateCapability({
193195 desktopManaged: serverConfig.mode === "desktop",
@@ -206,6 +208,7 @@ export const make = Effect.gen(function* () { · 12 unmodified lines
206208 platform: {
207209 os: platformOs(hostPlatform),
208210 arch: platformArch(hostArchitecture),
211+ ...(machine === null ? {} : { machine }),
209212 },
210213 serverVersion: packageJson.version,
211214 capabilities: {
@@ -222,6 +225,7 @@ export const make = Effect.gen(function* () { · 10 unmodified lines
222225 threadPinReorder: true,
223226 threadTitleRegeneration: true,
224227 threadPullRequestLinking: true,
228+ environmentIcon: true,
225229 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
226230 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
227231 ? {
apps/server/src/environment/ServerEnvironmentMachine.test.ts+225−0
@@ -0,0 +1,225 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as FileSystem from "effect/FileSystem";
4+import * as Layer from "effect/Layer";
5+import * as PlatformError from "effect/PlatformError";
6+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
7+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8+import { vi } from "vite-plus/test";
9+
10+import * as ProcessRunner from "../processRunner.ts";
11+import {
12+ detectServerEnvironmentMachineKind,
13+ machineKindFromAppleProductName,
14+ machineKindFromDmi,
15+} from "./ServerEnvironmentMachine.ts";
16+
17+const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
18+
19+const ProcessRunnerTest = Layer.succeed(
20+ ProcessRunner.ProcessRunner,
21+ ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }),
22+);
23+
24+const processOutput = (stdout: string, code = 0) =>
25+ Effect.succeed({
26+ stdout,
27+ stderr: "",
28+ code: ChildProcessSpawner.ExitCode(code),
29+ timedOut: false,
30+ stdoutTruncated: false,
31+ stderrTruncated: false,
32+ stdoutInvalidUtf8: false,
33+ stderrInvalidUtf8: false,
34+ });
35+
36+const dmiFileSystem = (files: Readonly<Record<string, string>>) =>
37+ FileSystem.layerNoop({
38+ readFileString: (path) => {
39+ const name = path.slice(path.lastIndexOf("/") + 1);
40+ return name in files
41+ ? Effect.succeed(files[name]!)
42+ : Effect.fail(
43+ PlatformError.systemError({
44+ _tag: "NotFound",
45+ module: "FileSystem",
46+ method: "readFileString",
47+ pathOrDescriptor: path,
48+ cause: new Error("ENOENT"),
49+ }),
50+ );
51+ },
52+ });
53+
54+const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) =>
55+ Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform));
56+
57+afterEach(() => {
58+ runMock.mockReset();
59+});
60+
61+describe("machineKindFromAppleProductName", () => {
62+ it("maps marketing names and model identifiers", () => {
63+ expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini");
64+ expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini");
65+ expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio");
66+ expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop");
67+ expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop");
68+ expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop");
69+ expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop");
70+ });
71+
72+ it("returns null for Apple silicon model identifiers, which carry no product family", () => {
73+ expect(machineKindFromAppleProductName("Mac16,10")).toBeNull();
74+ });
75+});
76+
77+describe("machineKindFromDmi", () => {
78+ it("prefers virtualization markers over chassis type", () => {
79+ expect(
80+ machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }),
81+ ).toBe("cloud");
82+ expect(
83+ machineKindFromDmi({
84+ chassisType: "3",
85+ sysVendor: "Microsoft Corporation",
86+ productName: "Virtual Machine",
87+ }),
88+ ).toBe("cloud");
89+ expect(
90+ machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }),
91+ ).toBe("cloud");
92+ });
93+
94+ it("does not treat Microsoft hardware as a VM", () => {
95+ expect(
96+ machineKindFromDmi({
97+ chassisType: "9",
98+ sysVendor: "Microsoft Corporation",
99+ productName: "Surface Laptop 5",
100+ }),
101+ ).toBe("laptop");
102+ });
103+
104+ it("maps SMBIOS chassis codes", () => {
105+ expect(
106+ machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }),
107+ ).toBe("desktop");
108+ expect(
109+ machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }),
110+ ).toBe("laptop");
111+ expect(
112+ machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }),
113+ ).toBe("server");
114+ expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull();
115+ expect(
116+ machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }),
117+ ).toBeNull();
118+ });
119+
120+ it("recognizes Apple hardware running Linux", () => {
121+ expect(
122+ machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }),
123+ ).toBe("mac-studio");
124+ });
125+});
126+
127+describe("detectServerEnvironmentMachineKind", () => {
128+ it.effect("reads the IOKit product name on macOS", () =>
129+ Effect.gen(function* () {
130+ runMock.mockReturnValueOnce(
131+ processOutput(
132+ '+-o product <class IOPlatformDevice>\n {\n "product-name" = <"Mac mini (2024)">\n }\n',
133+ ),
134+ );
135+
136+ const result = yield* detectServerEnvironmentMachineKind().pipe(
137+ Effect.provide(withPlatform("darwin")),
138+ );
139+
140+ expect(result).toBe("mac-mini");
141+ expect(runMock).toHaveBeenCalledTimes(1);
142+ expect(runMock).toHaveBeenCalledWith(
143+ expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }),
144+ );
145+ }),
146+ );
147+
148+ it.effect("falls back to hw.model when IOKit has no product node", () =>
149+ Effect.gen(function* () {
150+ runMock.mockReturnValueOnce(processOutput("", 1));
151+ runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n"));
152+
153+ const result = yield* detectServerEnvironmentMachineKind().pipe(
154+ Effect.provide(withPlatform("darwin")),
155+ );
156+
157+ expect(result).toBe("laptop");
158+ expect(runMock).toHaveBeenLastCalledWith(
159+ expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }),
160+ );
161+ }),
162+ );
163+
164+ it.effect("returns null when both macOS probes fail", () =>
165+ Effect.gen(function* () {
166+ runMock.mockImplementation((input) =>
167+ Effect.fail(
168+ new ProcessRunner.ProcessSpawnError({
169+ command: input.command,
170+ argumentCount: input.args.length,
171+ cause: new Error("ENOENT"),
172+ }),
173+ ),
174+ );
175+
176+ const result = yield* detectServerEnvironmentMachineKind().pipe(
177+ Effect.provide(withPlatform("darwin")),
178+ );
179+
180+ expect(result).toBeNull();
181+ expect(runMock).toHaveBeenCalledTimes(2);
182+ }),
183+ );
184+
185+ it.effect("reads DMI on Linux", () =>
186+ Effect.gen(function* () {
187+ const result = yield* detectServerEnvironmentMachineKind().pipe(
188+ Effect.provide(
189+ withPlatform(
190+ "linux",
191+ dmiFileSystem({
192+ chassis_type: "3\n",
193+ sys_vendor: "GMKtec\n",
194+ product_name: "NucBox K8 Plus\n",
195+ }),
196+ ),
197+ ),
198+ );
199+
200+ expect(result).toBe("desktop");
201+ expect(runMock).not.toHaveBeenCalled();
202+ }),
203+ );
204+
205+ it.effect("returns null on Linux without DMI (containers, ARM boards)", () =>
206+ Effect.gen(function* () {
207+ const result = yield* detectServerEnvironmentMachineKind().pipe(
208+ Effect.provide(withPlatform("linux", dmiFileSystem({}))),
209+ );
210+
211+ expect(result).toBeNull();
212+ }),
213+ );
214+
215+ it.effect("skips detection on other platforms", () =>
216+ Effect.gen(function* () {
217+ const result = yield* detectServerEnvironmentMachineKind().pipe(
218+ Effect.provide(withPlatform("win32")),
219+ );
220+
221+ expect(result).toBeNull();
222+ expect(runMock).not.toHaveBeenCalled();
223+ }),
224+ );
225+});
apps/server/src/environment/ServerEnvironmentMachine.ts+167−0
@@ -0,0 +1,167 @@
1+import type { EnvironmentMachineKind } from "@t3tools/contracts";
2+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+
6+import * as ProcessRunner from "../processRunner.ts";
7+
8+/**
9+ * Best-effort hardware detection for the environment icon. Every probe is
10+ * allowed to fail: a null result means "no signal", and the client draws a
11+ * generic server until the user picks something in Settings → Connections.
12+ */
13+
14+const DMI_ROOT = "/sys/class/dmi/id";
15+
16+// SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape
17+// rather than a machine (docking stations, blades enclosures, IoT gateways)
18+// fall through to null on purpose.
19+const DMI_CHASSIS_KINDS: Readonly<Record<string, EnvironmentMachineKind>> = {
20+ "3": "desktop", // Desktop
21+ "4": "desktop", // Low Profile Desktop
22+ "5": "desktop", // Pizza Box
23+ "6": "desktop", // Mini Tower
24+ "7": "desktop", // Tower
25+ "8": "laptop", // Portable
26+ "9": "laptop", // Laptop
27+ "10": "laptop", // Notebook
28+ "13": "desktop", // All in One
29+ "14": "laptop", // Sub Notebook
30+ "15": "desktop", // Space-saving
31+ "16": "desktop", // Lunch Box
32+ "17": "server", // Main Server Chassis
33+ "18": "server", // Expansion Chassis
34+ "19": "server", // SubChassis
35+ "20": "server", // Bus Expansion Chassis
36+ "21": "server", // Peripheral Chassis
37+ "22": "server", // RAID Chassis
38+ "23": "server", // Rack Mount Chassis
39+ "24": "server", // Sealed-case PC
40+ "28": "server", // Blade
41+ "31": "laptop", // Convertible
42+ "32": "laptop", // Detachable
43+ "35": "desktop", // Mini PC
44+};
45+
46+// Hypervisors and cloud providers write themselves into the DMI vendor or
47+// product strings; any hit means the box is a VM, and a VM reads as "cloud"
48+// regardless of the chassis type the hypervisor fakes. Hyper-V is matched on
49+// its "Virtual Machine" product, not the "Microsoft Corporation" vendor that
50+// physical Surface devices share.
51+const VIRTUALIZATION_MARKERS = [
52+ "qemu",
53+ "kvm",
54+ "bochs",
55+ "vmware",
56+ "virtualbox",
57+ "innotek",
58+ "xen",
59+ "parallels",
60+ "amazon ec2",
61+ "google compute engine",
62+ "digitalocean",
63+ "hetzner",
64+ "linode",
65+ "vultr",
66+ "scaleway",
67+ "openstack",
68+ "cloud",
69+ "virtual machine",
70+];
71+
72+function normalize(value: string | null | undefined): string | null {
73+ const trimmed = value?.trim();
74+ return trimmed && trimmed.length > 0 ? trimmed : null;
75+}
76+
77+/** Marketing names and Intel-era model identifiers share these prefixes. */
78+export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null {
79+ const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, "");
80+ if (normalized.startsWith("macmini")) return "mac-mini";
81+ if (normalized.startsWith("macstudio")) return "mac-studio";
82+ if (normalized.startsWith("macbook")) return "laptop";
83+ if (normalized.startsWith("imac") || normalized.startsWith("macpro")) return "desktop";
84+ return null;
85+}
86+
87+export function machineKindFromDmi(input: {
88+ readonly chassisType: string | null;
89+ readonly sysVendor: string | null;
90+ readonly productName: string | null;
91+}): EnvironmentMachineKind | null {
92+ const productName = input.productName ?? "";
93+ const vendorAndProduct = `${input.sysVendor ?? ""} ${productName}`.toLowerCase();
94+ if (VIRTUALIZATION_MARKERS.some((marker) => vendorAndProduct.includes(marker))) {
95+ return "cloud";
96+ }
97+ // Apple hardware booting Linux (Asahi) still reports the Apple product name.
98+ const appleKind = machineKindFromAppleProductName(productName);
99+ if (appleKind !== null) {
100+ return appleKind;
101+ }
102+ return input.chassisType === null ? null : (DMI_CHASSIS_KINDS[input.chassisType] ?? null);
103+}
104+
105+const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) {
106+ const fileSystem = yield* FileSystem.FileSystem;
107+ return yield* fileSystem.readFileString(path).pipe(
108+ Effect.map(normalize),
109+ Effect.catch(() => Effect.succeed(null)),
110+ );
111+});
112+
113+const runProbe = Effect.fn("runMachineProbe")(function* (input: {
114+ readonly command: string;
115+ readonly args: ReadonlyArray<string>;
116+}) {
117+ const processRunner = yield* ProcessRunner.ProcessRunner;
118+ return yield* processRunner
119+ .run({
120+ command: input.command,
121+ args: input.args,
122+ timeout: "5 seconds",
123+ timeoutBehavior: "timedOutResult",
124+ })
125+ .pipe(
126+ Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)),
127+ Effect.catch(() => Effect.succeed(null)),
128+ );
129+});
130+
131+// IOKit's `product` node carries the marketing name ("Mac mini (2024)") on
132+// Apple silicon; Intel Macs lack it, so `hw.model` ("Macmini8,1") is the
133+// fallback. Both are single-digit-millisecond calls.
134+const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* () {
135+ const ioreg = yield* runProbe({ command: "ioreg", args: ["-rd1", "-n", "product"] });
136+ const productName = ioreg?.match(/"product-name"\s*=\s*<"([^"]+)">/)?.[1] ?? null;
137+ const fromProductName =
138+ productName === null ? null : machineKindFromAppleProductName(productName);
139+ if (fromProductName !== null) {
140+ return fromProductName;
141+ }
142+ const model = yield* runProbe({ command: "sysctl", args: ["-n", "hw.model"] });
143+ return model === null ? null : machineKindFromAppleProductName(model);
144+});
145+
146+const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () {
147+ const [chassisType, sysVendor, productName] = yield* Effect.all([
148+ readOptionalFile(`${DMI_ROOT}/chassis_type`),
149+ readOptionalFile(`${DMI_ROOT}/sys_vendor`),
150+ readOptionalFile(`${DMI_ROOT}/product_name`),
151+ ]);
152+ return machineKindFromDmi({ chassisType, sysVendor, productName });
153+});
154+
155+export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")(
156+ function* () {
157+ const platform = yield* HostProcessPlatform;
158+ switch (platform) {
159+ case "darwin":
160+ return yield* detectDarwinMachineKind();
161+ case "linux":
162+ return yield* detectLinuxMachineKind();
163+ default:
164+ return null;
165+ }
166+ },
167+);
apps/web/src/components/BranchToolbar.logic.ts+2−1
@@ -1,4 +1,4 @@
1−import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
1+import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts";
22 import * as Schema from "effect/Schema";
33 import { toSortableTimestamp } from "../lib/threadSort";
44 export {
@@ -11,6 +11,7 @@ export interface EnvironmentOption { · 6 unmodified lines
1111 projectId: ProjectId;
1212 label: string;
1313 isPrimary: boolean;
14+ machine: EnvironmentMachineKind;
1415 }
1516
1617 export const EnvMode = Schema.Literals(["local", "worktree"]);
apps/web/src/components/BranchToolbar.tsx+17−19
@@ -2,16 +2,15 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ · 1 unmodified line
22 import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
33 import {
44 ChevronDownIcon,
5− CloudIcon,
65 FolderGit2Icon,
76 FolderGitIcon,
87 FolderIcon,
98 HistoryIcon,
10− MonitorIcon,
119 } from "lucide-react";
1210 import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1311
1412 import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
13+import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
1514 import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
1615 import { useIsMobile } from "../hooks/useMediaQuery";
1716 import {
@@ -105,12 +104,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ · 87 unmodified lines
105104 ? resolveEnvModeLabel("worktree")
106105 : resolveCurrentWorkspaceLabel(activeWorktreePath);
107106 const isLocked = envLocked || envModeLocked;
108− const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
109107 const icon = showEnvironmentIndicator ? (
110108 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px
111109 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.
112110 <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+ />
114115 <WorkspaceIcon className="size-3 shrink-0 mx-0!" />
115116 </span>
116117 ) : (
@@ -151,21 +152,18 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ · 34 unmodified lines
151152 value={environmentId}
152153 onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
153154 >
154− {availableEnvironments.map((env) => {
155− const Icon = env.isPrimary ? MonitorIcon : CloudIcon;
156− return (
157− <MenuRadioItem
158− key={env.environmentId}
159− disabled={envLocked}
160− value={env.environmentId}
161− >
162− <span className="flex min-w-0 items-center gap-1.5">
163− <Icon className="size-3" />
164− <span className="min-w-0 truncate">{env.label}</span>
165− </span>
166− </MenuRadioItem>
167− );
168− })}
155+ {availableEnvironments.map((env) => (
156+ <MenuRadioItem
157+ key={env.environmentId}
158+ disabled={envLocked}
159+ value={env.environmentId}
160+ >
161+ <span className="flex min-w-0 items-center gap-1.5">
162+ <EnvironmentMachineIcon kind={env.machine} className="size-3" />
163+ <span className="min-w-0 truncate">{env.label}</span>
164+ </span>
165+ </MenuRadioItem>
166+ ))}
169167 </MenuRadioGroup>
170168 </MenuGroup>
171169 <MenuSeparator />
apps/web/src/components/ChatView.tsx+6−8
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
2323 ProviderInteractionMode,
2424 ProviderDriverKind,
25+ resolveEnvironmentMachineKind,
2526 RuntimeMode,
2627 TerminalOpenInput,
2728 } from "@t3tools/contracts";
@@ -303,6 +304,7 @@ import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; · 275 unmodified lines
303304 import { NoActiveThreadState } from "./NoActiveThreadState";
304305 import { WorkspacePageHeader } from "./WorkspacePageHeader";
305306 import {
307+ type EnvironmentOption,
306308 resolveEffectiveEnvMode,
307309 resolveLocalCheckoutBranchMismatch,
308310 shouldShowComposerContextStrip,
@@ -2061,22 +2063,18 @@ function ChatViewContent(props: ChatViewProps) { · 1752 unmodified lines
20612063 (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey,
20622064 );
20632065 const seen = new Set<string>();
2064− const envs: Array<{
2065− environmentId: EnvironmentId;
2066− projectId: ProjectId;
2067− label: string;
2068− isPrimary: boolean;
2069− }> = [];
2066+ const envs: EnvironmentOption[] = [];
20702067 for (const p of memberProjects) {
20712068 if (seen.has(p.environmentId)) continue;
20722069 seen.add(p.environmentId);
20732070 const isPrimary = p.environmentId === primaryEnvironmentId;
2074− const label = environmentById.get(p.environmentId)?.label ?? p.environmentId;
2071+ const environment = environmentById.get(p.environmentId) ?? null;
20752072 envs.push({
20762073 environmentId: p.environmentId,
20772074 projectId: p.id,
2078− label,
2075+ label: environment?.label ?? p.environmentId,
20792076 isPrimary,
2077+ machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null),
20802078 });
20812079 }
20822080 // Sort: primary first, then alphabetical
apps/web/src/components/EnvironmentMachineIcon.tsx+75−0
@@ -0,0 +1,75 @@
1+import type { EnvironmentMachineKind } from "@t3tools/contracts";
2+import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react";
3+import type { FunctionComponent, SVGProps } from "react";
4+
5+// Lucide has no Apple desktops, so these two are drawn to its grammar (24
6+// unit grid, 2 unit stroke, round joins) and share its prop surface so callers
7+// can swap freely.
8+function LucideLike(props: SVGProps<SVGSVGElement>) {
9+ return (
10+ <svg
11+ xmlns="http://www.w3.org/2000/svg"
12+ width="24"
13+ height="24"
14+ viewBox="0 0 24 24"
15+ fill="none"
16+ stroke="currentColor"
17+ strokeWidth="2"
18+ strokeLinecap="round"
19+ strokeLinejoin="round"
20+ {...props}
21+ />
22+ );
23+}
24+
25+/** A Mac mini: squat rounded slab with a front-edge LED. */
26+export function MacMiniIcon(props: SVGProps<SVGSVGElement>) {
27+ return (
28+ <LucideLike {...props}>
29+ <rect width="20" height="8" x="2" y="8" rx="2" />
30+ <path d="M6 12h.01" />
31+ </LucideLike>
32+ );
33+}
34+
35+/** A Mac Studio: the same slab twice as tall, ports along the front foot. */
36+export function MacStudioIcon(props: SVGProps<SVGSVGElement>) {
37+ return (
38+ <LucideLike {...props}>
39+ <rect width="18" height="14" x="3" y="5" rx="2" />
40+ <path d="M7 15h.01M11 15h.01M15 15h.01" />
41+ </LucideLike>
42+ );
43+}
44+
45+const ICON_BY_KIND: Record<EnvironmentMachineKind, FunctionComponent<LucideProps>> = {
46+ server: ServerIcon,
47+ cloud: CloudIcon,
48+ desktop: MonitorIcon,
49+ laptop: LaptopIcon,
50+ "mac-mini": MacMiniIcon,
51+ "mac-studio": MacStudioIcon,
52+};
53+
54+export const ENVIRONMENT_MACHINE_KIND_LABELS: Record<EnvironmentMachineKind, string> = {
55+ server: "Server",
56+ cloud: "Cloud VM",
57+ desktop: "Desktop",
58+ laptop: "Laptop",
59+ "mac-mini": "Mac mini",
60+ "mac-studio": "Mac Studio",
61+};
62+
63+export function environmentMachineIcon(
64+ kind: EnvironmentMachineKind,
65+): FunctionComponent<LucideProps> {
66+ return ICON_BY_KIND[kind];
67+}
68+
69+export function EnvironmentMachineIcon({
70+ kind,
71+ ...props
72+}: LucideProps & { readonly kind: EnvironmentMachineKind }) {
73+ const Icon = ICON_BY_KIND[kind];
74+ return <Icon {...props} />;
75+}
apps/web/src/components/LegacySidebar.tsx+10−3
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 ThreadWorktreeIndicator,
2323 useLinkedThreadPullRequest,
2424 } from "./ThreadStatusIndicators";
25+import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
2526 import { ProjectFavicon } from "./ProjectFavicon";
2627 import { useAtomValue } from "@effect/atom-react";
2728 import { autoAnimate } from "@formkit/auto-animate";
@@ -48,6 +49,7 @@ import { · 20 unmodified lines
4849 type ScopedThreadRef,
4950 type ResolvedKeybindingsConfig,
5051 type SidebarProjectGroupingMode,
52+ resolveEnvironmentMachineKind,
5153 ThreadId,
5254 } from "@t3tools/contracts";
5355 import {
@@ -395,9 +397,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 341 unmodified lines
395397 });
396398 const environment = useEnvironment(thread.environmentId);
397399 const primaryEnvironmentId = usePrimaryEnvironmentId();
398− const isRemoteThread =
399− primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
400+ // No primary (the hosted app) means every thread is remote, and the machine
401+ // glyph is what tells the environments apart.
402+ const isRemoteThread = thread.environmentId !== primaryEnvironmentId;
400403 const remoteEnvLabel = environment?.label ?? null;
404+ const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);
401405 // A desktop-local secondary backend (e.g. the WSL backend) shows up as a
402406 // bearer environment whose connection id is prefixed "local:". It runs on the
403407 // user's own machine, so the cloud icon is misleading — label it "Local" and
@@ -876,7 +880,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 472 unmodified lines
876880 />
877881 }
878882 >
879− <CloudIcon className="size-3 text-muted-foreground/40" />
883+ <EnvironmentMachineIcon
884+ kind={remoteMachine}
885+ className="size-3 text-muted-foreground/40"
886+ />
880887 </TooltipTrigger>
881888 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
882889 </Tooltip>
apps/web/src/components/Sidebar.tsx+46−6
@@ -29,7 +29,12 @@ import { · 28 unmodified lines
2929 scopeThreadRef,
3030 scopedThreadKey,
3131 } from "@t3tools/client-runtime/environment";
32−import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
32+import {
33+ resolveEnvironmentMachineKind,
34+ type EnvironmentMachineKind,
35+ type ScopedThreadRef,
36+ type ThreadId,
37+} from "@t3tools/contracts";
3338 import type { TimestampFormat } from "@t3tools/contracts/settings";
3439 import {
3540 AlarmClockIcon,
@@ -47,7 +52,6 @@ import { · 11 unmodified lines
4752 PinIcon,
4853 PlusIcon,
4954 SearchIcon,
50− ServerIcon,
5155 SettingsIcon,
5256 SquarePenIcon,
5357 TerminalIcon,
@@ -121,6 +125,7 @@ import { · 67 unmodified lines
121125 import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
122126 import type { SidebarThreadSummary } from "../types";
123127 import { cn } from "~/lib/utils";
128+import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
124129 import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
125130 import {
126131 animatePinnedLayoutChanges,
@@ -274,6 +279,7 @@ function SidebarThreadTooltip({ · 147 unmodified lines
274279 projectCwd,
275280 projectFaviconPath,
276281 environmentLabel,
282+ environmentMachine,
277283 providerEntry,
278284 showInstanceBadge,
279285 modelInstanceId,
@@ -287,6 +293,7 @@ function SidebarThreadTooltip({ · 7 unmodified lines
287293 projectCwd: string | null;
288294 projectFaviconPath: string | null;
289295 environmentLabel: string | null;
296+ environmentMachine: EnvironmentMachineKind;
290297 providerEntry: ProviderInstanceEntry | null;
291298 showInstanceBadge: boolean;
292299 modelInstanceId: string;
@@ -325,7 +332,10 @@ function SidebarThreadTooltip({ · 32 unmodified lines
325332 ) : null}
326333 {environmentLabel ? (
327334 <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+ />
329339 <div className="min-w-0 truncate text-foreground/75">{environmentLabel}</div>
330340 </div>
331341 ) : null}
@@ -735,6 +745,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 403 unmodified lines
735745 jumpLabel: string | null;
736746 currentEnvironmentId: string | null;
737747 environmentLabel: string | null;
748+ environmentMachine: EnvironmentMachineKind;
738749 projectCwd: string | null;
739750 projectFaviconPath: string | null;
740751 projectTitle: string | null;
@@ -963,8 +974,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 222 unmodified lines
963974 ? getTriggerDisplayModelLabel(selectedModel)
964975 : thread.modelSelection.model;
965976
966− const isRemote =
967− props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
977+ // The local environment is "this machine" and needs no marker; every other
978+ // one gets its machine glyph. With no local environment (the hosted app)
979+ // that is every thread, which is the point: the glyph is what tells rows on
980+ // different machines apart.
981+ const isRemote = thread.environmentId !== props.currentEnvironmentId;
968982
969983 const detailsTooltip = (
970984 <SidebarThreadTooltip
@@ -973,6 +987,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 2 unmodified lines
973987 projectCwd={props.projectCwd}
974988 projectFaviconPath={props.projectFaviconPath}
975989 environmentLabel={props.environmentLabel}
990+ environmentMachine={props.environmentMachine}
976991 providerEntry={providerEntry}
977992 showInstanceBadge={showInstanceBadge}
978993 modelInstanceId={modelInstanceId}
@@ -1590,7 +1605,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 611 unmodified lines
15901605 >
15911606 {isRemote ? (
15921607 <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+ />
15941613 </span>
15951614 ) : null}
15961615 {driverKind ? (
@@ -1636,6 +1655,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { · 39 unmodified lines
16361655 projectFaviconPath: string | null;
16371656 projectTitle: string | null;
16381657 environmentLabel: string | null;
1658+ environmentMachine: EnvironmentMachineKind;
16391659 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
16401660 isHighlighted: boolean;
16411661 isRouteActive: boolean;
@@ -1731,6 +1751,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { · 89 unmodified lines
17311751 projectCwd={props.projectCwd}
17321752 projectFaviconPath={props.projectFaviconPath}
17331753 environmentLabel={props.environmentLabel}
1754+ environmentMachine={props.environmentMachine}
17341755 providerEntry={providerEntry}
17351756 showInstanceBadge={showInstanceBadge}
17361757 modelInstanceId={modelInstanceId}
@@ -1871,6 +1892,19 @@ export default function Sidebar() { · 134 unmodified lines
18711892 ),
18721893 [environments],
18731894 );
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+ );
18741908 const orderedProjects = useMemo(
18751909 () =>
18761910 orderItemsByPreferredIds({
@@ -3717,6 +3751,9 @@ export default function Sidebar() { · 1840 unmodified lines
37173751 ) ?? null
37183752 }
37193753 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3754+ environmentMachine={
3755+ environmentMachineById.get(thread.environmentId) ?? "server"
3756+ }
37203757 providerEntryByInstanceId={
37213758 providerEntriesByEnvironment.get(thread.environmentId) ??
37223759 EMPTY_PROVIDER_ENTRIES
@@ -3815,6 +3852,9 @@ export default function Sidebar() { · 92 unmodified lines
38153852 }
38163853 currentEnvironmentId={primaryEnvironmentId}
38173854 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3855+ environmentMachine={
3856+ environmentMachineById.get(thread.environmentId) ?? "server"
3857+ }
38183858 projectCwd={
38193859 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
38203860 }
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+19−11
@@ -1,13 +1,14 @@
11 import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
22 import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3−import type {
4− EnvironmentId,
5− PullRequestAction,
6− PullRequestMergeMethod,
7− PullRequestUpdateMethod,
8− PullRequestRef,
9− PullRequestState,
10− ScopedThreadRef,
3+import {
4+ type EnvironmentId,
5+ type PullRequestAction,
6+ type PullRequestMergeMethod,
7+ type PullRequestUpdateMethod,
8+ type PullRequestRef,
9+ type PullRequestState,
10+ resolveEnvironmentMachineKind,
11+ type ScopedThreadRef,
1112 } from "@t3tools/contracts";
1213 import {
1314 ArrowDownUpIcon,
@@ -36,7 +37,6 @@ import { · 22 unmodified lines
3637 PlayIcon,
3738 RefreshCwIcon,
3839 RotateCcwIcon,
39− ServerIcon,
4040 TriangleAlertIcon,
4141 } from "lucide-react";
4242 import {
@@ -77,6 +77,7 @@ import { · 34 unmodified lines
7777 AlertDialogPopup,
7878 AlertDialogTitle,
7979 } from "../ui/alert-dialog";
80+import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
8081 import { Badge } from "../ui/badge";
8182 import { Button } from "../ui/button";
8283 import { Input } from "../ui/input";
@@ -272,7 +273,10 @@ function ActOnEnvironmentPicker({ · 189 unmodified lines
272273 {/* The radio item lays its children out as one block, so the icon and the label
273274 need their own row to share a line. */}
274275 <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+ />
276280 <span className="truncate">{environment.label}</span>
277281 </span>
278282 </MenuRadioItem>
@@ -729,7 +733,11 @@ export function PullRequestDetailPanel({ · 450 unmodified lines
729733 ? resolvePickableEnvironments(
730734 { environmentId, projectId: reference.projectId },
731735 projects,
732− environments,
736+ environments.map((environment) => ({
737+ environmentId: environment.environmentId,
738+ label: environment.label,
739+ machine: resolveEnvironmentMachineKind(environment.serverConfig),
740+ })),
733741 )
734742 : [],
735743 [context, environmentId, environments, projects, reference.projectId],
apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts+13−6
@@ -1,4 +1,4 @@
1−import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1+import type { EnvironmentId, EnvironmentMachineKind, ProjectId } from "@t3tools/contracts";
22
33 /** The little of a project this needs: who holds it, and which repository it is a copy of. */
44 export interface AssignableProject {
@@ -69,6 +69,7 @@ export interface PickableEnvironment { · 64 unmodified lines
6969 readonly projectId: ProjectId;
7070 readonly workspaceRoot: string;
7171 readonly label: string;
72+ readonly machine?: EnvironmentMachineKind;
7273 }
7374
7475 /**
@@ -85,17 +86,21 @@ export interface PickableEnvironment { · 10 unmodified lines
8586 export function resolvePickableEnvironments(
8687 current: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
8788 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+ }>,
8994 ): ReadonlyArray<PickableEnvironment> {
9095 const own = projects.find(
9196 (project) =>
9297 project.environmentId === current.environmentId && project.id === current.projectId,
9398 );
9499 const key = own === undefined ? undefined : repositoryKey(own);
95− const ownLabel = environments.find(
100+ const ownEnvironment = environments.find(
96101 (environment) => environment.environmentId === current.environmentId,
97− )?.label;
98− if (own === undefined || !key || ownLabel === undefined) return [];
102+ );
103+ if (own === undefined || !key || ownEnvironment === undefined) return [];
99104 const others = environments.flatMap((environment) => {
100105 if (environment.environmentId === current.environmentId) return [];
101106 // One entry per server, whichever copy comes first: a server holding two worktrees of the
@@ -112,6 +117,7 @@ export function resolvePickableEnvironments( · 10 unmodified lines
112117 projectId: copy.id,
113118 workspaceRoot: copy.workspaceRoot,
114119 label: environment.label,
120+ ...(environment.machine === undefined ? {} : { machine: environment.machine }),
115121 },
116122 ];
117123 });
@@ -123,7 +129,8 @@ export function resolvePickableEnvironments( · 5 unmodified lines
123129 environmentId: current.environmentId,
124130 projectId: own.id,
125131 workspaceRoot: own.workspaceRoot,
126− label: ownLabel,
132+ label: ownEnvironment.label,
133+ ...(ownEnvironment.machine === undefined ? {} : { machine: ownEnvironment.machine }),
127134 },
128135 ...others,
129136 ];
apps/web/src/components/settings/ConnectionsSettings.tsx+29−0
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 type DesktopServerExposureState,
2828 type DesktopWslState,
2929 type EnvironmentId,
30+ resolveEnvironmentMachineKind,
3031 } from "@t3tools/contracts";
3132 import { connectionStatusText } from "@t3tools/client-runtime/connection";
3233 import {
@@ -53,6 +54,7 @@ import { · 20 unmodified lines
5354 useRelativeTimeTick,
5455 } from "./settingsLayout";
5556 import { searchableSetting } from "./settingsSearch";
57+import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
5658 import { Input } from "../ui/input";
5759 import { Checkbox } from "../ui/checkbox";
5860 import {
@@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; · 27 unmodified lines
8688 import { Button } from "../ui/button";
8789 import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
8890 import { AnimatedHeight } from "../AnimatedHeight";
91+import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
8992 import { Textarea } from "../ui/textarea";
9093 import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
9194 import { readHostedPairingRequest } from "../../hostedPairing";
@@ -1438,6 +1441,11 @@ function SavedBackendListRow({ · 1346 unmodified lines
14381441 : null
14391442 }
14401443 />
1444+ <EnvironmentMachineIcon
1445+ aria-hidden
1446+ kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1447+ className="size-3.5 shrink-0 text-muted-foreground"
1448+ />
14411449 <h3 className="min-w-0 truncate text-sm font-medium text-foreground">
14421450 {environment.label}
14431451 </h3>
@@ -1445,6 +1453,15 @@ function SavedBackendListRow({ · 1 unmodified line
14451453 {metadataBits.length > 0 ? (
14461454 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
14471455 ) : 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}
14481465 {serverUpdateState.status !== "idle" ? (
14491466 <div className="max-w-md">
14501467 <ServerUpdateProgress state={serverUpdateState} />
@@ -3081,6 +3098,18 @@ export function ConnectionsSettings() { · 1630 unmodified lines
30813098 }
30823099 />
30833100 ) : 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}
30843113 {desktopBridge ? (
30853114 <>
30863115 {renderNetworkAccessRow()}
apps/web/src/components/settings/EnvironmentIconPicker.test.ts+41−0
@@ -0,0 +1,41 @@
1+import type { ServerConfig } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { resolveEnvironmentIconPickerLock } from "./EnvironmentIconPicker";
5+
6+const config = (environmentIcon: boolean | undefined) =>
7+ ({
8+ environment: { capabilities: environmentIcon === undefined ? {} : { environmentIcon } },
9+ }) as unknown as ServerConfig;
10+
11+describe("resolveEnvironmentIconPickerLock", () => {
12+ it("locks until the environment is connected", () => {
13+ expect(
14+ resolveEnvironmentIconPickerLock({ serverConfig: null, operateAccess: "granted" }),
15+ ).toMatch(/Connect/);
16+ });
17+
18+ it("locks on servers that predate the setting, before looking at permissions", () => {
19+ expect(
20+ resolveEnvironmentIconPickerLock({
21+ serverConfig: config(undefined),
22+ operateAccess: "denied",
23+ }),
24+ ).toMatch(/too old/);
25+ });
26+
27+ it("locks when the session cannot operate the environment", () => {
28+ expect(
29+ resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "denied" }),
30+ ).toMatch(/cannot change/);
31+ });
32+
33+ it("stays open while access is still resolving so a slow session does not flicker", () => {
34+ expect(
35+ resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }),
36+ ).toBeNull();
37+ expect(
38+ resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }),
39+ ).toBeNull();
40+ });
41+});
apps/web/src/components/settings/EnvironmentIconPicker.tsx+161−0
@@ -0,0 +1,161 @@
1+import {
2+ ENVIRONMENT_MACHINE_KINDS,
3+ isEnvironmentMachineKind,
4+ resolveEnvironmentMachineKind,
5+ type EnvironmentId,
6+ type ServerConfig,
7+} from "@t3tools/contracts";
8+import { useCallback } from "react";
9+
10+import { isElectron } from "../../env";
11+import { usePrimarySessionState } from "../../environments/primary";
12+import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
13+import { usePrimaryEnvironmentId } from "../../state/environments";
14+import { useEnvironmentSessionState } from "../../state/session";
15+import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
16+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
17+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
18+import {
19+ resolvePrimaryOperateAccess,
20+ resolveRemoteOperateAccess,
21+} from "./ProviderSettingsPanel.logic";
22+
23+const AUTOMATIC_VALUE = "automatic";
24+
25+/**
26+ * Why the picker is inert, in the order the user can do something about it.
27+ * Null means it can be changed.
28+ */
29+export function resolveEnvironmentIconPickerLock(input: {
30+ readonly serverConfig: ServerConfig | null;
31+ readonly operateAccess: "granted" | "denied" | "pending";
32+}): string | null {
33+ if (input.serverConfig === null) {
34+ return "Connect to this environment to change its icon.";
35+ }
36+ if (input.serverConfig.environment.capabilities.environmentIcon !== true) {
37+ return "This environment's server is too old to keep an icon. Update it to choose one.";
38+ }
39+ if (input.operateAccess === "denied") {
40+ return "Your session on this environment cannot change its settings.";
41+ }
42+ return null;
43+}
44+
45+// Same split the provider settings use: the desktop app owns its primary
46+// server outright, a browser session on the primary checks its cookie
47+// session's scopes, and a remote checks the scopes its own server reports.
48+function useEnvironmentOperateAccess(environmentId: EnvironmentId) {
49+ const isPrimary = usePrimaryEnvironmentId() === environmentId;
50+ const primarySession = usePrimarySessionState();
51+ const remoteSession = useEnvironmentSessionState(environmentId);
52+ if (isPrimary) {
53+ return isElectron
54+ ? "granted"
55+ : resolvePrimaryOperateAccess({
56+ isPrimary: true,
57+ hasDesktopBridge: false,
58+ session: primarySession.data,
59+ isPending: primarySession.isPending,
60+ hasError: primarySession.error !== null,
61+ });
62+ }
63+ return resolveRemoteOperateAccess({
64+ session: remoteSession.data,
65+ isPending: remoteSession.isPending,
66+ hasError: remoteSession.hasError,
67+ });
68+}
69+
70+/**
71+ * Picks the machine glyph an environment wears everywhere it is listed.
72+ * "Automatic" clears the override so the server's own detection shows
73+ * through; the label says what that currently resolves to so the user can
74+ * tell whether detection got it right before overriding. The control stays
75+ * visible while locked so the current icon still reads, the same way
76+ * server-scoped rows go inert instead of disappearing.
77+ */
78+export function EnvironmentIconPicker({
79+ environmentId,
80+ serverConfig,
81+ size = "sm",
82+}: {
83+ readonly environmentId: EnvironmentId;
84+ readonly serverConfig: ServerConfig | null;
85+ readonly size?: "xs" | "sm";
86+}) {
87+ const updateSettings = useUpdateEnvironmentSettings(environmentId);
88+ const operateAccess = useEnvironmentOperateAccess(environmentId);
89+ const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });
90+ const override = serverConfig?.settings.environmentIcon ?? null;
91+ const detected = serverConfig?.environment.platform.machine ?? null;
92+ const resolved = resolveEnvironmentMachineKind(serverConfig);
93+ const value = override ?? AUTOMATIC_VALUE;
94+ const automaticLabel =
95+ detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`;
96+
97+ const handleValueChange = useCallback(
98+ (next: string | null) => {
99+ if (next === null) return;
100+ if (next === AUTOMATIC_VALUE) {
101+ updateSettings({ environmentIcon: null });
102+ } else if (isEnvironmentMachineKind(next)) {
103+ updateSettings({ environmentIcon: next });
104+ }
105+ },
106+ [updateSettings],
107+ );
108+
109+ const select = (
110+ <Select value={value} onValueChange={handleValueChange} disabled={lock !== null}>
111+ <SelectTrigger size={size} className="w-full sm:w-52" aria-label="Environment icon">
112+ <SelectValue>
113+ <span className="flex min-w-0 items-center gap-2">
114+ <EnvironmentMachineIcon kind={resolved} className="size-3.5 shrink-0" />
115+ <span className="truncate">
116+ {override === null ? automaticLabel : ENVIRONMENT_MACHINE_KIND_LABELS[override]}
117+ </span>
118+ </span>
119+ </SelectValue>
120+ </SelectTrigger>
121+ <SelectPopup align="end" alignItemWithTrigger={false}>
122+ <SelectItem hideIndicator value={AUTOMATIC_VALUE}>
123+ <span className="flex min-w-0 items-center gap-2">
124+ <EnvironmentMachineIcon kind={detected ?? "server"} className="size-3.5 shrink-0" />
125+ <span className="truncate">{automaticLabel}</span>
126+ </span>
127+ </SelectItem>
128+ {ENVIRONMENT_MACHINE_KINDS.map((kind) => (
129+ <SelectItem hideIndicator key={kind} value={kind}>
130+ <span className="flex min-w-0 items-center gap-2">
131+ <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" />
132+ <span className="truncate">{ENVIRONMENT_MACHINE_KIND_LABELS[kind]}</span>
133+ </span>
134+ </SelectItem>
135+ ))}
136+ </SelectPopup>
137+ </Select>
138+ );
139+
140+ if (lock === null) {
141+ return select;
142+ }
143+ return (
144+ <Tooltip>
145+ <TooltipTrigger
146+ render={
147+ // Focusable so keyboard users can still reach the explanation.
148+ <span
149+ tabIndex={0}
150+ className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
151+ />
152+ }
153+ >
154+ <span className="flex w-full items-center sm:w-auto">{select}</span>
155+ </TooltipTrigger>
156+ <TooltipPopup side="top" className="max-w-72">
157+ {lock}
158+ </TooltipPopup>
159+ </Tooltip>
160+ );
161+}
apps/web/src/components/settings/settingsSearch.ts+8−0
@@ -406,6 +406,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 405 unmodified lines
406406 ],
407407 primaryOnly: true,
408408 },
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+ },
409417 {
410418 id: "network-access",
411419 title: "Network access",
apps/web/src/routes/_chat.pull-requests.tsx+5−6
@@ -1,5 +1,5 @@
11 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2−import { pullRequestHostOf, ThreadId } from "@t3tools/contracts";
2+import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
33 import type {
44 EnvironmentId,
55 ProjectId,
@@ -19,8 +19,6 @@ import { · 13 unmodified lines
1919 ChevronDownIcon,
2020 ClockIcon,
2121 EyeIcon,
22− MonitorIcon,
23− ServerIcon,
2422 GitMergeIcon,
2523 GitPullRequestClosedIcon,
2624 GitPullRequestIcon,
@@ -86,6 +84,7 @@ import { · 59 unmodified lines
8684 writePullRequestListPreferences,
8785 } from "../components/pullRequest/pullRequestListPreferences";
8886 import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
87+import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
8988 import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
9089 import {
9190 PullRequestFiltersMenu,
@@ -1736,14 +1735,14 @@ function PullRequestsRouteView() { · 1644 unmodified lines
17361735 };
17371736 }),
17381737 ];
1739− // The same shape the host pills take, so the two groups read as one control. A local
1740− // connection wears the screen it is on; every other server wears a server.
1738+ // The same shape the host pills take, so the two groups read as one control. Each server
1739+ // wears the machine it runs on.
17411740 const serverMenuOptions: ReadonlyArray<PullRequestFilterOption<string>> = [
17421741 { value: "", label: "All servers", Icon: LayersIcon },
17431742 ...capableEnvironments.map((environment) => ({
17441743 value: environment.environmentId,
17451744 label: environment.label,
1746− Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon,
1745+ Icon: environmentMachineIcon(resolveEnvironmentMachineKind(environment.serverConfig)),
17471746 })),
17481747 ];
17491748 const sortMenu = (
docs/internals/remote.md+17−0
@@ -224,6 +224,22 @@ right action without making the transport responsible for process management. Th · 223 unmodified lines
224224 supervisor owns the resulting disconnect and reconnect like any other involuntary close. See
225225 [server-updates.md](./server-updates.md).
226226
227+## Machine identity
228+
229+Every environment is drawn with one of a fixed set of machine glyphs (`EnvironmentMachineKind` in
230+contracts: server, cloud, desktop, laptop, mac-mini, mac-studio). Two inputs feed it, and the
231+precedence lives in one helper, `resolveEnvironmentMachineKind`, so web and mobile cannot drift:
232+
233+1. `settings.environmentIcon`, a nullable server setting the user picks in Settings → Connections.
234+ It is server state rather than client state so every device connecting to that server agrees.
235+2. `environment.platform.machine`, detected once at startup by [ServerEnvironmentMachine.ts][machine]
236+ (IOKit product name / `hw.model` on macOS, `/sys/class/dmi/id` on Linux with virtualization
237+ markers mapped to "cloud"). Absent when there is no signal, so older servers, Windows hosts, and
238+ containers decode without it.
239+3. A generic server otherwise.
240+
241+Clients treat the field like any other capability: absent means "use the fallback", never "wait".
242+
227243 ## Future work
228244
229245 These remain unbuilt and are listed to keep the model honest:
@@ -237,3 +253,4 @@ These remain unbuilt and are listed to keep the model honest: · 7 unmodified lines
237253 [authremote]: ../../packages/client-runtime/src/authorization/remote.ts
238254 [sshenv]: ../../apps/desktop/src/ssh/DesktopSshEnvironment.ts
239255 [sshtunnel]: ../../packages/ssh/src/tunnel.ts
256+[machine]: ../../apps/server/src/environment/ServerEnvironmentMachine.ts
docs/user/thread-sidebar.md+15−0
@@ -53,6 +53,21 @@ The main sidebar, right panel, and terminal drawer open and close immediately by · 52 unmodified lines
5353 The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at
5454 0 ms, it snaps between the same open and closed states.
5555
56+## Environment icons
57+
58+When you are connected to more than one environment, every thread that lives somewhere other than
59+the machine you are on wears a small icon for that machine at the end of its row: a server, a cloud
60+VM, a desktop, a laptop, a Mac mini, or a Mac Studio. In the hosted web app and the mobile app,
61+where every environment is remote, each row wears its machine so you can tell them apart at a
62+glance. The same icon appears in the thread tooltip, the "Run on" picker, the pull request server
63+filter, and the environment lists under **Settings → Connections**.
64+
65+Servers pick the icon themselves from the hardware they run on. A Mac reports its model, a Linux
66+machine reports its chassis type and whether it is a virtual machine, and anything without a usable
67+signal shows a generic server. To override it, open **Settings → Connections** and choose an icon
68+for that environment; **Automatic** goes back to what the server detected. The choice is stored on
69+that server, so every device that connects to it sees the same icon.
70+
5671 ## Environment artwork
5772
5873 Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
packages/contracts/src/baseSchemas.ts+39−0
@@ -43,6 +43,45 @@ export type IsoDateTime = typeof IsoDateTime.Type; · 42 unmodified lines
4343 * rejecting the payload would take down the connection over data the client
4444 * couldn't act on anyway. Encoding is the plain array encoding.
4545 */
46+/**
47+ * Same idea for one optional value whose literal set grows over time: a
48+ * member this build does not know decodes as absent rather than failing the
49+ * enclosing struct. Encoding is the plain encoding.
50+ */
51+export const ForwardCompatibleOptional = <Value extends Schema.Top>(value: Value) => {
52+ const decodeValue = Schema.decodeUnknownOption(value as never);
53+ return Schema.optionalKey(
54+ Schema.Unknown.pipe(
55+ Schema.decodeTo(
56+ Schema.UndefinedOr(value),
57+ SchemaTransformation.transform<Value["Encoded"] | undefined, unknown>({
58+ decode: (raw) =>
59+ Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : undefined,
60+ encode: (raw) => raw,
61+ }),
62+ ),
63+ ),
64+ );
65+};
66+
67+/**
68+ * The nullable form, for a persisted setting whose literal set grows over
69+ * time: a member this build does not know (or a missing key) decodes as null
70+ * rather than failing the enclosing struct. Encoding is the plain encoding.
71+ */
72+export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value) => {
73+ const decodeValue = Schema.decodeUnknownOption(value as never);
74+ return Schema.Unknown.pipe(
75+ Schema.decodeTo(
76+ Schema.NullOr(value),
77+ SchemaTransformation.transform<Value["Encoded"] | null, unknown>({
78+ decode: (raw) => (Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : null),
79+ encode: (raw) => raw,
80+ }),
81+ ),
82+ );
83+};
84+
4685 export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {
4786 const decodeElement = Schema.decodeUnknownOption(element as never);
4887 return Schema.Array(Schema.Unknown).pipe(
packages/contracts/src/environment.ts+32−1
@@ -1,7 +1,13 @@
11 import * as Effect from "effect/Effect";
22 import * as Schema from "effect/Schema";
33
4−import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4+import {
5+ EnvironmentId,
6+ ForwardCompatibleOptional,
7+ ProjectId,
8+ ThreadId,
9+ TrimmedNonEmptyString,
10+} from "./baseSchemas.ts";
511
612 export const ExecutionEnvironmentPlatformOs = Schema.Literals([
713 "darwin",
@@ -14,9 +20,30 @@ export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatform · 6 unmodified lines
1420 export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]);
1521 export 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+ */
28+export const ENVIRONMENT_MACHINE_KINDS = [
29+ "server",
30+ "cloud",
31+ "desktop",
32+ "laptop",
33+ "mac-mini",
34+ "mac-studio",
35+] as const;
36+export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS);
37+export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type;
38+export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind);
39+
1740 export const ExecutionEnvironmentPlatform = Schema.Struct({
1841 os: ExecutionEnvironmentPlatformOs,
1942 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),
2047 });
2148
2249 /**
@@ -102,6 +129,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ · 79 unmodified lines
102129 this is false — no update would ever repaint it. Absent on older
103130 servers, which may still publish, so only an explicit false skips. */
104131 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),
105136 /** The desktop app supervising this server can be driven over RPC:
106137 server.updateServer runs its check -> download -> relaunch. Absent on
107138 desktop servers whose app predates the remote trigger, where clients
packages/contracts/src/server.test.ts+53−0
@@ -1,12 +1,15 @@
11 import * as Schema from "effect/Schema";
22 import { describe, expect, it } from "vite-plus/test";
33
4+import { ExecutionEnvironmentDescriptor } from "./environment.ts";
45 import {
6+ resolveEnvironmentMachineKind,
57 ServerConfig,
68 ServerProvider,
79 ServerProviders,
810 ServerUpsertKeybindingResult,
911 } from "./server.ts";
12+import { ServerSettings } from "./settings.ts";
1013
1114 const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
1215 const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);
@@ -155,3 +158,53 @@ describe("server config forward compatibility", () => { · 142 unmodified lines
155158 expect(parsed).toEqual([decodedBase]);
156159 });
157160 });
161+
162+describe("resolveEnvironmentMachineKind", () => {
163+ const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor);
164+ const decodeSettings = Schema.decodeUnknownSync(ServerSettings);
165+ const descriptor = (platform: Record<string, unknown>) =>
166+ decodeDescriptor({
167+ environmentId: "env-1",
168+ label: "Box",
169+ platform: { os: "linux", arch: "x64", ...platform },
170+ serverVersion: "1.0.0",
171+ capabilities: {},
172+ });
173+
174+ it("prefers the user's pick over what the server detected", () => {
175+ expect(
176+ resolveEnvironmentMachineKind({
177+ environment: descriptor({ machine: "mac-mini" }),
178+ settings: decodeSettings({ environmentIcon: "laptop" }),
179+ }),
180+ ).toBe("laptop");
181+ });
182+
183+ it("uses detection when nothing is picked", () => {
184+ expect(
185+ resolveEnvironmentMachineKind({
186+ environment: descriptor({ machine: "mac-mini" }),
187+ settings: decodeSettings({}),
188+ }),
189+ ).toBe("mac-mini");
190+ });
191+
192+ it("falls back to a server for older servers and before connect", () => {
193+ expect(
194+ resolveEnvironmentMachineKind({
195+ environment: descriptor({}),
196+ settings: decodeSettings({}),
197+ }),
198+ ).toBe("server");
199+ expect(resolveEnvironmentMachineKind(null)).toBe("server");
200+ });
201+
202+ it("drops a machine kind this build does not know instead of failing the descriptor", () => {
203+ const parsed = descriptor({ machine: "toaster" });
204+
205+ expect(parsed.platform.machine).toBeUndefined();
206+ expect(
207+ resolveEnvironmentMachineKind({ environment: parsed, settings: decodeSettings({}) }),
208+ ).toBe("server");
209+ });
210+});
packages/contracts/src/server.ts+17−1
@@ -1,6 +1,10 @@
11 import * as Effect from "effect/Effect";
22 import * as Schema from "effect/Schema";
3−import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
3+import {
4+ type EnvironmentMachineKind,
5+ ExecutionEnvironmentDescriptor,
6+ ServerSelfUpdateMethod,
7+} from "./environment.ts";
48 import { ServerAuthDescriptor } from "./auth.ts";
59 import {
610 ForwardCompatibleArray,
@@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({ · 565 unmodified lines
572576 });
573577 export 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+ */
585+export function resolveEnvironmentMachineKind(
586+ config: Pick<ServerConfig, "environment" | "settings"> | null,
587+): EnvironmentMachineKind {
588+ return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server";
589+}
590+
575591 const ServerUpsertKeybindingReplaceTarget = Schema.Struct({
576592 key: KeybindingValue,
577593 command: KeybindingCommand,
packages/contracts/src/settings.test.ts+19−0
@@ -505,3 +505,22 @@ describe("ServerSettingsPatch string normalization", () => { · 504 unmodified lines
505505 expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config");
506506 });
507507 });
508+
509+describe("ServerSettings environment icon", () => {
510+ it("defaults to null", () => {
511+ expect(decodeServerSettings({}).environmentIcon).toBeNull();
512+ });
513+
514+ it("keeps a kind this build knows", () => {
515+ expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini");
516+ });
517+
518+ it("decodes a kind from a newer server as null instead of failing the snapshot", () => {
519+ expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull();
520+ });
521+
522+ it("round-trips through encode", () => {
523+ const settings = decodeServerSettings({ environmentIcon: "laptop" });
524+ expect(encodeServerSettings(settings).environmentIcon).toBe("laptop");
525+ });
526+});
packages/contracts/src/settings.ts+13−2
@@ -2,8 +2,8 @@ import * as Effect from "effect/Effect"; · 1 unmodified line
22 import * as Duration from "effect/Duration";
33 import * as Schema from "effect/Schema";
44 import * as SchemaTransformation from "effect/SchemaTransformation";
5−import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6−import { ThreadEnvMode } from "./environment.ts";
5+import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6+import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
77 import {
88 DEFAULT_TEXT_GENERATION_MODEL,
99 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
@@ -734,6 +734,16 @@ export const ServerSettings = Schema.Struct({ · 724 unmodified lines
734734 defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe(
735735 Schema.withDecodingDefault(Effect.succeed("")),
736736 ),
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+ ),
737747 defaultThreadEnvMode: ThreadEnvMode.pipe(
738748 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
739749 ),
@@ -945,6 +955,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 205 unmodified lines
945955 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
946956 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
947957 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
958+ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)),
948959 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
949960 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
950961 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),