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";
2222import IconCamera from "@tabler/icons-react-native/IconCamera";
2323import IconChartBar from "@tabler/icons-react-native/IconChartBar";
2424import IconCheck from "@tabler/icons-react-native/IconCheck";
25import IconCloud from "@tabler/icons-react-native/IconCloud";
2526import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";
2627import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";
2728import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
......@@ -32,6 +33,7 @@ import IconClock from "@tabler/icons-react-native/IconClock";
3233import IconCode from "@tabler/icons-react-native/IconCode";
3334import IconCopy from "@tabler/icons-react-native/IconCopy";
3435import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
36import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop";
3537import IconDots from "@tabler/icons-react-native/IconDots";
3638import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
3739import IconEdit from "@tabler/icons-react-native/IconEdit";
......@@ -110,6 +112,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
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>> = {
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 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 {
44 connectionStatusText,
55 type EnvironmentConnectionPhase,
66} 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";
813import { useCallback, useState } from "react";
914import {
1015 ActivityIndicator,
......@@ -15,10 +20,12 @@ import {
1520} from "react-native";
1621
1722import { AppText as Text } from "../../components/AppText";
23import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1824import { ThemedSwitch } from "../../components/ThemedSwitch";
1925import { cn } from "../../lib/cn";
2026import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
2127import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
28import { serverEnvironment } from "../../state/server";
2229import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
2330import { hasCloudPublicConfig } from "../cloud/publicConfig";
2431import { ConnectionStatusDot } from "./ConnectionStatusDot";
......@@ -205,6 +212,9 @@ function ConnectedCloudEnvironmentRow(props: {
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: {
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: {
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: {
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 @@
11import { SymbolView } from "../../components/AppSymbol";
22import { connectionStatusText } from "@t3tools/client-runtime/connection";
33import 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";
56import * as Cause from "effect/Cause";
67import { AsyncResult } from "effect/unstable/reactivity";
78import { useCallback, useState } from "react";
......@@ -9,9 +10,11 @@ import { Alert, Pressable, View } from "react-native";
910import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1011
1112import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1214import { cn } from "../../lib/cn";
1315import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1416import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
17import { serverEnvironment } from "../../state/server";
1518import { ConnectionStatusDot } from "./ConnectionStatusDot";
1619
1720function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
......@@ -35,6 +38,9 @@ export function ConnectionEnvironmentRow(props: {
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: {
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 {
1212 type EnvironmentThreadSearchMatch,
1313} from "@t3tools/client-runtime/state/thread-search";
1414import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import type {
16 EnvironmentId,
17 SidebarProjectGroupingMode,
18 SidebarThreadSortOrder,
15import {
16 type EnvironmentId,
17 resolveEnvironmentMachineKind,
18 type SidebarProjectGroupingMode,
19 type SidebarThreadSortOrder,
1920} from "@t3tools/contracts";
2021import { useAtomSet, useAtomValue } from "@effect/atom-react";
2122import { AsyncResult } from "effect/unstable/reactivity";
......@@ -612,6 +613,16 @@ export function HomeScreen(props: HomeScreenProps) {
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) {
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) {
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) {
847860 handleSwipeableWillOpen,
848861 handleUnsettleThread,
849862 pinningEnvironmentIds,
863 machineByEnvironmentId,
850864 pinReorderEnvironmentIds,
851865 projectByKey,
852866 projectCwdByKey,
......@@ -938,6 +952,9 @@ export function HomeScreen(props: HomeScreenProps) {
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) {
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) {
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 {
99import { LegendList } from "@legendapp/list/react-native";
1010import type { MenuAction } from "@react-native-menu/menu";
1111import { useAtomValue } from "@effect/atom-react";
12import type { EnvironmentId } from "@t3tools/contracts";
12import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
1313import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1414import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1515import type { LayoutChangeEvent } from "react-native";
......@@ -441,6 +441,16 @@ function ThreadNavigationSidebarPane(
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(
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(
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(
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(
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(
10171035 handleSelectThread,
10181036 handleSwipeableClose,
10191037 handleSwipeableWillOpen,
1038 machineByEnvironmentId,
10201039 movePinnedThread,
10211040 openPendingTask,
10221041 pinReorderEnvironmentIds,
apps/mobile/src/features/threads/thread-list-items.tsx+24
......@@ -4,6 +4,7 @@ import type {
44 EnvironmentThreadShell,
55} from "@t3tools/client-runtime/state/shell";
66import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
7import type { EnvironmentMachineKind } from "@t3tools/contracts";
78import type { MenuAction } from "@react-native-menu/menu";
89import { SymbolView } from "../../components/AppSymbol";
910import { memo, useCallback, useMemo, type ComponentProps } from "react";
......@@ -14,6 +15,7 @@ import Svg, { Circle, Path } from "react-native-svg";
1415
1516import { AppText as Text } from "../../components/AppText";
1617import { ControlPillMenu } from "../../components/ControlPill";
18import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1719import { ProjectFavicon } from "../../components/ProjectFavicon";
1820import { cn } from "../../lib/cn";
1921import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
......@@ -268,6 +270,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
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: {
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: {
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: {
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 {
33 EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
55import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import type { EnvironmentMachineKind } from "@t3tools/contracts";
67import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
78import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
89import type { MenuAction } from "@react-native-menu/menu";
......@@ -13,6 +14,7 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw
1314import { SymbolView } from "../../components/AppSymbol";
1415import { AppText as Text } from "../../components/AppText";
1516import { ControlPillMenu } from "../../components/ControlPill";
17import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
1618import { ProjectFavicon } from "../../components/ProjectFavicon";
1719import { ProviderIcon } from "../../components/ProviderIcon";
1820import { cn } from "../../lib/cn";
......@@ -201,6 +203,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
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
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: {
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: {
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
......@@ -20,6 +20,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
2020import * as ServerConfig from "../config.ts";
2121import * as ProcessRunner from "../processRunner.ts";
2222import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
23import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts";
2324
2425export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(
2526 "ServerEnvironmentIdPersistenceError",
......@@ -188,6 +189,7 @@ export const make = Effect.gen(function* () {
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* () {
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* () {
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 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 @@
1import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
1import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
33import { toSortableTimestamp } from "../lib/threadSort";
44export {
......@@ -11,6 +11,7 @@ export interface EnvironmentOption {
1111 projectId: ProjectId;
1212 label: string;
1313 isPrimary: boolean;
14 machine: EnvironmentMachineKind;
1415}
1516
1617export 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
22import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
33import {
44 ChevronDownIcon,
5 CloudIcon,
65 FolderGit2Icon,
76 FolderGitIcon,
87 FolderIcon,
98 HistoryIcon,
10 MonitorIcon,
119} from "lucide-react";
1210import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1311
1412import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
13import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
1514import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
1615import { useIsMobile } from "../hooks/useMediaQuery";
1716import {
......@@ -105,12 +104,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
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({
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 {
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";
303304import { NoActiveThreadState } from "./NoActiveThreadState";
304305import { WorkspacePageHeader } from "./WorkspacePageHeader";
305306import {
307 type EnvironmentOption,
306308 resolveEffectiveEnvMode,
307309 resolveLocalCheckoutBranchMismatch,
308310 shouldShowComposerContextStrip,
......@@ -2061,22 +2063,18 @@ function ChatViewContent(props: ChatViewProps) {
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 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 {
2222 ThreadWorktreeIndicator,
2323 useLinkedThreadPullRequest,
2424} from "./ThreadStatusIndicators";
25import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
2526import { ProjectFavicon } from "./ProjectFavicon";
2627import { useAtomValue } from "@effect/atom-react";
2728import { autoAnimate } from "@formkit/auto-animate";
......@@ -48,6 +49,7 @@ import {
4849 type ScopedThreadRef,
4950 type ResolvedKeybindingsConfig,
5051 type SidebarProjectGroupingMode,
52 resolveEnvironmentMachineKind,
5153 ThreadId,
5254} from "@t3tools/contracts";
5355import {
......@@ -395,9 +397,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
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
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 {
2929 scopeThreadRef,
3030 scopedThreadKey,
3131} 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";
3338import type { TimestampFormat } from "@t3tools/contracts/settings";
3439import {
3540 AlarmClockIcon,
......@@ -47,7 +52,6 @@ import {
4752 PinIcon,
4853 PlusIcon,
4954 SearchIcon,
50 ServerIcon,
5155 SettingsIcon,
5256 SquarePenIcon,
5357 TerminalIcon,
......@@ -121,6 +125,7 @@ import {
121125import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";
122126import type { SidebarThreadSummary } from "../types";
123127import { cn } from "~/lib/utils";
128import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
124129import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
125130import {
126131 animatePinnedLayoutChanges,
......@@ -274,6 +279,7 @@ function SidebarThreadTooltip({
274279 projectCwd,
275280 projectFaviconPath,
276281 environmentLabel,
282 environmentMachine,
277283 providerEntry,
278284 showInstanceBadge,
279285 modelInstanceId,
......@@ -287,6 +293,7 @@ function SidebarThreadTooltip({
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({
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: {
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: {
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: {
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: {
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: {
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: {
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() {
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() {
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() {
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 @@
11import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
22import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type {
4 EnvironmentId,
5 PullRequestAction,
6 PullRequestMergeMethod,
7 PullRequestUpdateMethod,
8 PullRequestRef,
9 PullRequestState,
10 ScopedThreadRef,
3import {
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";
1213import {
1314 ArrowDownUpIcon,
......@@ -36,7 +37,6 @@ import {
3637 PlayIcon,
3738 RefreshCwIcon,
3839 RotateCcwIcon,
39 ServerIcon,
4040 TriangleAlertIcon,
4141} from "lucide-react";
4242import {
......@@ -77,6 +77,7 @@ import {
7777 AlertDialogPopup,
7878 AlertDialogTitle,
7979} from "../ui/alert-dialog";
80import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
8081import { Badge } from "../ui/badge";
8182import { Button } from "../ui/button";
8283import { Input } from "../ui/input";
......@@ -272,7 +273,10 @@ function ActOnEnvironmentPicker({
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({
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 @@
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1import 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. */
44export interface AssignableProject {
......@@ -69,6 +69,7 @@ export interface PickableEnvironment {
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 {
8586export 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(
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(
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
......@@ -27,6 +27,7 @@ import {
2727 type DesktopServerExposureState,
2828 type DesktopWslState,
2929 type EnvironmentId,
30 resolveEnvironmentMachineKind,
3031} from "@t3tools/contracts";
3132import { connectionStatusText } from "@t3tools/client-runtime/connection";
3233import {
......@@ -53,6 +54,7 @@ import {
5354 useRelativeTimeTick,
5455} from "./settingsLayout";
5556import { searchableSetting } from "./settingsSearch";
57import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
5658import { Input } from "../ui/input";
5759import { Checkbox } from "../ui/checkbox";
5860import {
......@@ -86,6 +88,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8688import { Button } from "../ui/button";
8789import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
8890import { AnimatedHeight } from "../AnimatedHeight";
91import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
8992import { Textarea } from "../ui/textarea";
9093import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
9194import { readHostedPairingRequest } from "../../hostedPairing";
......@@ -1438,6 +1441,11 @@ function SavedBackendListRow({
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({
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() {
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 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 = [
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 @@
11import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { pullRequestHostOf, ThreadId } from "@t3tools/contracts";
2import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts";
33import type {
44 EnvironmentId,
55 ProjectId,
......@@ -19,8 +19,6 @@ import {
1919 ChevronDownIcon,
2020 ClockIcon,
2121 EyeIcon,
22 MonitorIcon,
23 ServerIcon,
2422 GitMergeIcon,
2523 GitPullRequestClosedIcon,
2624 GitPullRequestIcon,
......@@ -86,6 +84,7 @@ import {
8684 writePullRequestListPreferences,
8785} from "../components/pullRequest/pullRequestListPreferences";
8886import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
87import { environmentMachineIcon } from "../components/EnvironmentMachineIcon";
8988import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
9089import {
9190 PullRequestFiltersMenu,
......@@ -1736,14 +1735,14 @@ function PullRequestsRouteView() {
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
......@@ -224,6 +224,22 @@ right action without making the transport responsible for process management. Th
224224supervisor owns the resulting disconnect and reconnect like any other involuntary close. See
225225[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
227243## Future work
228244
229245These 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:
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
......@@ -53,6 +53,21 @@ The main sidebar, right panel, and terminal drawer open and close immediately by
5353The duration can be set up to 400 ms. Clicking the preview replays all three panel transitions; at
54540 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
5671## Environment artwork
5772
5873Dev 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;
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 */
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
4685export 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 @@
11import * as Effect from "effect/Effect";
22import * 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
612export const ExecutionEnvironmentPlatformOs = Schema.Literals([
713 "darwin",
......@@ -14,9 +20,30 @@ export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatform
1420export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]);
1521export 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
1740export 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({
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
......@@ -1,12 +1,15 @@
11import * as Schema from "effect/Schema";
22import { describe, expect, it } from "vite-plus/test";
33
4import { ExecutionEnvironmentDescriptor } from "./environment.ts";
45import {
6 resolveEnvironmentMachineKind,
57 ServerConfig,
68 ServerProvider,
79 ServerProviders,
810 ServerUpsertKeybindingResult,
911} from "./server.ts";
12import { ServerSettings } from "./settings.ts";
1013
1114const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
1215const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);
......@@ -155,3 +158,53 @@ describe("server config forward compatibility", () => {
155158 expect(parsed).toEqual([decodedBase]);
156159 });
157160});
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 @@
11import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
3import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
3import {
4 type EnvironmentMachineKind,
5 ExecutionEnvironmentDescriptor,
6 ServerSelfUpdateMethod,
7} from "./environment.ts";
48import { ServerAuthDescriptor } from "./auth.ts";
59import {
610 ForwardCompatibleArray,
......@@ -572,6 +576,18 @@ export const ServerConfig = Schema.Struct({
572576});
573577export 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
575591const ServerUpsertKeybindingReplaceTarget = Schema.Struct({
576592 key: KeybindingValue,
577593 command: KeybindingCommand,
packages/contracts/src/settings.test.ts+19
......@@ -505,3 +505,22 @@ describe("ServerSettingsPatch string normalization", () => {
505505 expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config");
506506 });
507507});
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";
22import * as Duration from "effect/Duration";
33import * as Schema from "effect/Schema";
44import * as SchemaTransformation from "effect/SchemaTransformation";
5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { ThreadEnvMode } from "./environment.ts";
5import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
77import {
88 DEFAULT_TEXT_GENERATION_MODEL,
99 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
......@@ -734,6 +734,16 @@ export const ServerSettings = Schema.Struct({
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({
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),