authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 23:23:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 23:23:44-07:00
log829c3db94830fc70b5754513b8a370f7301ca213
treec06233d52836383e03d9ae02a55f6f65a8500212
parent5eb4f452ee0fcde7b5bbc93d62de9118b70a33b2
signature Signed by PGP key B5690EEEBB952194

fix(environments): draw the machine icon everywhere an environment is named (#9365)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 130 insertions(+), 70 deletions(-)

apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+29-6
...@@ -3,7 +3,11 @@ import type {...@@ -3,7 +3,11 @@ import type {
3 EnvironmentThreadShell,3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";5import { LegendList } from "@legendapp/list/react-native";
6import type { EnvironmentId } from "@t3tools/contracts";6import {
7 type EnvironmentId,
8 type EnvironmentMachineKind,
9 resolveEnvironmentMachineKind,
10} from "@t3tools/contracts";
7import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";12import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
9import { SymbolView } from "../../components/AppSymbol";13import { SymbolView } from "../../components/AppSymbol";
...@@ -24,10 +28,12 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw...@@ -24,10 +28,12 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw
24import { AppText as Text } from "../../components/AppText";28import { AppText as Text } from "../../components/AppText";
25import { ControlPillMenu } from "../../components/ControlPill";29import { ControlPillMenu } from "../../components/ControlPill";
26import { EmptyState } from "../../components/EmptyState";30import { EmptyState } from "../../components/EmptyState";
31import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
27import { ProjectFavicon } from "../../components/ProjectFavicon";32import { ProjectFavicon } from "../../components/ProjectFavicon";
28import { useSafeAreaInsets } from "react-native-safe-area-context";33import { useSafeAreaInsets } from "react-native-safe-area-context";
29import { relativeTime } from "../../lib/time";34import { relativeTime } from "../../lib/time";
30import { useUniwindTheme } from "../../lib/useUniwindTheme";35import { useUniwindTheme } from "../../lib/useUniwindTheme";
36import { useServerConfigs } from "../../state/entities";
31import { ThreadSwipeable } from "../home/thread-swipe-actions";37import { ThreadSwipeable } from "../home/thread-swipe-actions";
32import {38import {
33 createNativeMailSearchToolbarItem,39 createNativeMailSearchToolbarItem,
...@@ -45,6 +51,7 @@ type ArchivedThreadListItem =...@@ -45,6 +51,7 @@ type ArchivedThreadListItem =
45 readonly kind: "project";51 readonly kind: "project";
46 readonly key: string;52 readonly key: string;
47 readonly environmentLabel: string | null;53 readonly environmentLabel: string | null;
54 readonly environmentMachine: EnvironmentMachineKind;
48 readonly project: EnvironmentProject;55 readonly project: EnvironmentProject;
49 }56 }
50 | {57 | {
...@@ -360,6 +367,7 @@ function ArchivedThreadsHeader(props: {...@@ -360,6 +367,7 @@ function ArchivedThreadsHeader(props: {
360367
361function ProjectGroupLabel(props: {368function ProjectGroupLabel(props: {
362 readonly environmentLabel: string | null;369 readonly environmentLabel: string | null;
370 readonly environmentMachine: EnvironmentMachineKind;
363 readonly project: EnvironmentProject;371 readonly project: EnvironmentProject;
364}) {372}) {
365 return (373 return (
...@@ -378,9 +386,16 @@ function ProjectGroupLabel(props: {...@@ -378,9 +386,16 @@ function ProjectGroupLabel(props: {
378 {props.project.title}386 {props.project.title}
379 </Text>387 </Text>
380 {props.environmentLabel ? (388 {props.environmentLabel ? (
381 <Text className="max-w-[42%] text-2xs text-foreground-tertiary" numberOfLines={1}>389 <View className="max-w-[42%] flex-row items-center gap-1">
382 {props.environmentLabel}390 <EnvironmentMachineSymbol
383 </Text>391 kind={props.environmentMachine}
392 size={10}
393 tintColorClassName="accent-foreground-tertiary"
394 />
395 <Text className="shrink text-2xs text-foreground-tertiary" numberOfLines={1}>
396 {props.environmentLabel}
397 </Text>
398 </View>
384 ) : null}399 ) : null}
385 </View>400 </View>
386 );401 );
...@@ -517,6 +532,7 @@ export function ArchivedThreadsScreen(props: {...@@ -517,6 +532,7 @@ export function ArchivedThreadsScreen(props: {
517 ),532 ),
518 [props.environments],533 [props.environments],
519 );534 );
535 const serverConfigs = useServerConfigs();
520 const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {536 const listItems = useMemo<ReadonlyArray<ArchivedThreadListItem>>(() => {
521 const items: ArchivedThreadListItem[] = [];537 const items: ArchivedThreadListItem[] = [];
522 for (const group of props.groups) {538 for (const group of props.groups) {
...@@ -525,6 +541,9 @@ export function ArchivedThreadsScreen(props: {...@@ -525,6 +541,9 @@ export function ArchivedThreadsScreen(props: {
525 kind: "project",541 kind: "project",
526 key: `${group.key}:project`,542 key: `${group.key}:project`,
527 environmentLabel,543 environmentLabel,
544 environmentMachine: resolveEnvironmentMachineKind(
545 serverConfigs.get(group.project.environmentId) ?? null,
546 ),
528 project: group.project,547 project: group.project,
529 });548 });
530549
...@@ -540,7 +559,7 @@ export function ArchivedThreadsScreen(props: {...@@ -540,7 +559,7 @@ export function ArchivedThreadsScreen(props: {
540 });559 });
541 }560 }
542 return items;561 return items;
543 }, [environmentLabelsById, props.groups]);562 }, [environmentLabelsById, props.groups, serverConfigs]);
544 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {563 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
545 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {564 if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
546 openSwipeableRef.current.close();565 openSwipeableRef.current.close();
...@@ -559,7 +578,11 @@ export function ArchivedThreadsScreen(props: {...@@ -559,7 +578,11 @@ export function ArchivedThreadsScreen(props: {
559 if (item.kind === "project") {578 if (item.kind === "project") {
560 return (579 return (
561 <View className="pt-4">580 <View className="pt-4">
562 <ProjectGroupLabel environmentLabel={item.environmentLabel} project={item.project} />581 <ProjectGroupLabel
582 environmentLabel={item.environmentLabel}
583 environmentMachine={item.environmentMachine}
584 project={item.project}
585 />
563 </View>586 </View>
564 );587 );
565 }588 }
apps/mobile/src/features/projects/AddProjectScreen.tsx+13-5
...@@ -31,7 +31,13 @@ import {...@@ -31,7 +31,13 @@ import {
31 inferProjectTitleFromPath,31 inferProjectTitleFromPath,
32 isWindowsPlatform,32 isWindowsPlatform,
33} from "@t3tools/client-runtime/state/projects";33} from "@t3tools/client-runtime/state/projects";
34import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";34import {
35 CommandId,
36 type EnvironmentId,
37 type EnvironmentMachineKind,
38 ProjectId,
39 resolveEnvironmentMachineKind,
40} from "@t3tools/contracts";
35import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";41import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
36import { SymbolView } from "../../components/AppSymbol";42import { SymbolView } from "../../components/AppSymbol";
37import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";43import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
...@@ -49,6 +55,7 @@ import { projectEnvironment } from "../../state/projects";...@@ -49,6 +55,7 @@ import { projectEnvironment } from "../../state/projects";
49import { useEnvironmentQuery } from "../../state/query";55import { useEnvironmentQuery } from "../../state/query";
50import { sourceControlEnvironment } from "../../state/sourceControl";56import { sourceControlEnvironment } from "../../state/sourceControl";
51import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";57import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
58import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
52import { ErrorBanner } from "../../components/ErrorBanner";59import { ErrorBanner } from "../../components/ErrorBanner";
53import { SourceControlIcon } from "../../components/SourceControlIcon";60import { SourceControlIcon } from "../../components/SourceControlIcon";
54import { uuidv4 } from "../../lib/uuid";61import { uuidv4 } from "../../lib/uuid";
...@@ -65,6 +72,7 @@ interface EnvironmentOption {...@@ -65,6 +72,7 @@ interface EnvironmentOption {
65 readonly environmentId: EnvironmentId;72 readonly environmentId: EnvironmentId;
66 readonly label: string;73 readonly label: string;
67 readonly platform: string;74 readonly platform: string;
75 readonly machine: EnvironmentMachineKind;
68 readonly baseDirectory: string | null;76 readonly baseDirectory: string | null;
69 readonly connectionState: EnvironmentConnectionPhase;77 readonly connectionState: EnvironmentConnectionPhase;
70 readonly connectionError: string | null;78 readonly connectionError: string | null;
...@@ -352,6 +360,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {...@@ -352,6 +360,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
352 environmentId: connection.environmentId,360 environmentId: connection.environmentId,
353 label: connection.environmentLabel,361 label: connection.environmentLabel,
354 platform: platformFromOs(config?.environment.platform.os ?? null),362 platform: platformFromOs(config?.environment.platform.os ?? null),
363 machine: resolveEnvironmentMachineKind(config ?? null),
355 baseDirectory: config?.settings.addProjectBaseDirectory ?? null,364 baseDirectory: config?.settings.addProjectBaseDirectory ?? null,
356 connectionState: runtime?.connectionState ?? "available",365 connectionState: runtime?.connectionState ?? "available",
357 connectionError: runtime?.connectionError ?? null,366 connectionError: runtime?.connectionError ?? null,
...@@ -490,11 +499,10 @@ export function AddProjectSourceScreen() {...@@ -490,11 +499,10 @@ export function AddProjectSourceScreen() {
490 })499 })
491 }500 }
492 icon={501 icon={
493 <SymbolView502 <EnvironmentMachineSymbol
494 name="server.rack"503 kind={environment.machine}
495 size={17}504 size={17}
496 tintColorClassName={"accent-icon"}505 tintColorClassName="accent-icon"
497 type="monochrome"
498 />506 />
499 }507 }
500 selected={environment.environmentId === selectedEnvironment?.environmentId}508 selected={environment.environmentId === selectedEnvironment?.environmentId}
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx+9-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { type EnvironmentMachineKind, resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { AsyncResult } from "effect/unstable/reactivity";3import { AsyncResult } from "effect/unstable/reactivity";
3import { useMemo } from "react";4import { useMemo } from "react";
4import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";5import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
...@@ -6,11 +7,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -6,11 +7,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
67
7import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";9import { SymbolView } from "../../components/AppSymbol";
10import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
9import {11import {
10 clearClientCacheAtom,12 clearClientCacheAtom,
11 clientCacheSummaryAtom,13 clientCacheSummaryAtom,
12 type EnvironmentClientCacheSummary,14 type EnvironmentClientCacheSummary,
13} from "../../state/client-cache-state";15} from "../../state/client-cache-state";
16import { useServerConfigs } from "../../state/entities";
14import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";17import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
15import { SettingsSection } from "./components/SettingsSection";18import { SettingsSection } from "./components/SettingsSection";
1619
...@@ -20,6 +23,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -20,6 +23,7 @@ export function SettingsClientStorageRouteScreen() {
20 const clearResult = useAtomValue(clearClientCacheAtom);23 const clearResult = useAtomValue(clearClientCacheAtom);
21 const clearCache = useAtomSet(clearClientCacheAtom);24 const clearCache = useAtomSet(clearClientCacheAtom);
22 const { savedConnectionsById } = useSavedRemoteConnections();25 const { savedConnectionsById } = useSavedRemoteConnections();
26 const serverConfigs = useServerConfigs();
23 const isClearing = clearResult.waiting;27 const isClearing = clearResult.waiting;
24 const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null;28 const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null;
25 const environmentSummaries = useMemo(29 const environmentSummaries = useMemo(
...@@ -106,6 +110,9 @@ export function SettingsClientStorageRouteScreen() {...@@ -106,6 +110,9 @@ export function SettingsClientStorageRouteScreen() {
106 savedConnectionsById[environment.environmentId]?.environmentLabel ??110 savedConnectionsById[environment.environmentId]?.environmentLabel ??
107 environment.environmentId111 environment.environmentId
108 }112 }
113 machine={resolveEnvironmentMachineKind(
114 serverConfigs.get(environment.environmentId) ?? null,
115 )}
109 disabled={isClearing}116 disabled={isClearing}
110 first={index === 0}117 first={index === 0}
111 onClear={() => confirmClearEnvironment(environment)}118 onClear={() => confirmClearEnvironment(environment)}
...@@ -169,6 +176,7 @@ export function SettingsClientStorageRouteScreen() {...@@ -169,6 +176,7 @@ export function SettingsClientStorageRouteScreen() {
169function CacheEnvironmentRow(props: {176function CacheEnvironmentRow(props: {
170 readonly environment: EnvironmentClientCacheSummary;177 readonly environment: EnvironmentClientCacheSummary;
171 readonly environmentLabel: string;178 readonly environmentLabel: string;
179 readonly machine: EnvironmentMachineKind;
172 readonly disabled: boolean;180 readonly disabled: boolean;
173 readonly first: boolean;181 readonly first: boolean;
174 readonly onClear: () => void;182 readonly onClear: () => void;
...@@ -181,13 +189,7 @@ function CacheEnvironmentRow(props: {...@@ -181,13 +189,7 @@ function CacheEnvironmentRow(props: {
181 : "border-t border-border flex-row items-center gap-3 p-4"189 : "border-t border-border flex-row items-center gap-3 p-4"
182 }190 }
183 >191 >
184 <SymbolView192 <EnvironmentMachineSymbol kind={props.machine} size={22} tintColorClassName="accent-icon" />
185 name="desktopcomputer"
186 size={22}
187 tintColorClassName={"accent-icon"}
188 type="monochrome"
189 weight="regular"
190 />
191 <Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={1}>193 <Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={1}>
192 {props.environmentLabel}194 {props.environmentLabel}
193 </Text>195 </Text>
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+19-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { VcsRef } from "@t3tools/client-runtime/state/vcs";1import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
2import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { LegendList } from "@legendapp/list/react-native";3import { LegendList } from "@legendapp/list/react-native";
3import {4import {
4 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
...@@ -21,9 +22,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -21,9 +22,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
21import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";22import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22import { SymbolView } from "../../components/AppSymbol";23import { SymbolView } from "../../components/AppSymbol";
23import { AppText as Text } from "../../components/AppText";24import { AppText as Text } from "../../components/AppText";
25import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
24import { ThemedSwitch } from "../../components/ThemedSwitch";26import { ThemedSwitch } from "../../components/ThemedSwitch";
25import { cn } from "../../lib/cn";27import { cn } from "../../lib/cn";
26import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";28import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
29import { useServerConfigs } from "../../state/entities";
27import { useAtomCommand } from "../../state/use-atom-command";30import { useAtomCommand } from "../../state/use-atom-command";
28import { vcsEnvironment } from "../../state/vcs";31import { vcsEnvironment } from "../../state/vcs";
29import {32import {
...@@ -35,7 +38,7 @@ import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";...@@ -35,7 +38,7 @@ import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
35import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";38import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
3639
37function SelectionRow(props: {40function SelectionRow(props: {
38 readonly icon?: "arrow.triangle.branch" | "desktopcomputer";41 readonly icon?: "arrow.triangle.branch" | ReactNode;
39 readonly onPress: () => void;42 readonly onPress: () => void;
40 readonly disabled?: boolean;43 readonly disabled?: boolean;
41 readonly selected: boolean;44 readonly selected: boolean;
...@@ -56,14 +59,16 @@ function SelectionRow(props: {...@@ -56,14 +59,16 @@ function SelectionRow(props: {
56 onPress={props.onPress}59 onPress={props.onPress}
57 style={{ opacity: props.disabled ? 0.45 : 1 }}60 style={{ opacity: props.disabled ? 0.45 : 1 }}
58 >61 >
59 {props.icon ? (62 {props.icon === "arrow.triangle.branch" ? (
60 <SymbolView63 <SymbolView
61 name={props.icon}64 name="arrow.triangle.branch"
62 size={17}65 size={17}
63 tintColorClassName={"accent-icon-muted"}66 tintColorClassName={"accent-icon-muted"}
64 type="monochrome"67 type="monochrome"
65 />68 />
66 ) : null}69 ) : (
70 (props.icon ?? null)
71 )}
67 <View className="min-w-0 flex-1 gap-0.5">72 <View className="min-w-0 flex-1 gap-0.5">
68 <Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>73 <Text className="text-base font-t3-medium text-foreground" numberOfLines={1}>
69 {props.title}74 {props.title}
...@@ -145,6 +150,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -145,6 +150,7 @@ export function NewTaskEnvironmentPickerRouteScreen() {
145 const flow = useNewTaskFlow();150 const flow = useNewTaskFlow();
146 const navigation = useNavigation();151 const navigation = useNavigation();
147 const insets = useSafeAreaInsets();152 const insets = useSafeAreaInsets();
153 const serverConfigs = useServerConfigs();
148154
149 return (155 return (
150 <View className="flex-1 bg-sheet" collapsable={false}>156 <View className="flex-1 bg-sheet" collapsable={false}>
...@@ -170,7 +176,15 @@ export function NewTaskEnvironmentPickerRouteScreen() {...@@ -170,7 +176,15 @@ export function NewTaskEnvironmentPickerRouteScreen() {
170 {flow.environments.map((environment, index) => (176 {flow.environments.map((environment, index) => (
171 <SelectionRow177 <SelectionRow
172 key={String(environment.environmentId)}178 key={String(environment.environmentId)}
173 icon="desktopcomputer"179 icon={
180 <EnvironmentMachineSymbol
181 kind={resolveEnvironmentMachineKind(
182 serverConfigs.get(environment.environmentId) ?? null,
183 )}
184 size={17}
185 tintColorClassName="accent-icon-muted"
186 />
187 }
174 isLast={index === flow.environments.length - 1}188 isLast={index === flow.environments.length - 1}
175 onPress={() => {189 onPress={() => {
176 void Haptics.selectionAsync();190 void Haptics.selectionAsync();
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+12-2
...@@ -24,7 +24,10 @@ import {...@@ -24,7 +24,10 @@ import {
24 isAtomCommandInterrupted,24 isAtomCommandInterrupted,
25 squashAtomCommandFailure,25 squashAtomCommandFailure,
26} from "@t3tools/client-runtime/state/runtime";26} from "@t3tools/client-runtime/state/runtime";
27import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";27import {
28 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
29 resolveEnvironmentMachineKind,
30} from "@t3tools/contracts";
2831
29import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";32import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
30import {33import {
...@@ -36,6 +39,7 @@ import {...@@ -36,6 +39,7 @@ import {
36import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";39import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
37import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";40import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
38import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";41import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
42import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
39import {43import {
40 composerAttachmentUploadBlockReason,44 composerAttachmentUploadBlockReason,
41 composerAttachmentUploadsAtom,45 composerAttachmentUploadsAtom,
...@@ -1129,7 +1133,13 @@ export function NewTaskDraftScreen(props: {...@@ -1129,7 +1133,13 @@ export function NewTaskDraftScreen(props: {
1129 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}1133 accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`}
1130 chevronDirection="right"1134 chevronDirection="right"
1131 disabled={isComposerInteractionLocked || voiceInput.isBusy}1135 disabled={isComposerInteractionLocked || voiceInput.isBusy}
1132 icon="desktopcomputer"1136 iconNode={
1137 <EnvironmentMachineSymbol
1138 kind={resolveEnvironmentMachineKind(selectedEnvironmentServerConfig)}
1139 size={16}
1140 tintColorClassName="accent-icon-muted"
1141 />
1142 }
1133 label={`on ${selectedEnvironmentLabel}`}1143 label={`on ${selectedEnvironmentLabel}`}
1134 maxWidth={260}1144 maxWidth={260}
1135 onPress={1145 onPress={
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+10-16
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { CloudIcon, MonitorIcon } from "lucide-react";
3import { memo, useMemo } from "react";2import { memo, useMemo } from "react";
43
5import type { EnvironmentOption } from "./BranchToolbar.logic";4import type { EnvironmentOption } from "./BranchToolbar.logic";
5import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
6import {6import {
7 Select,7 Select,
8 SelectGroup,8 SelectGroup,
...@@ -52,11 +52,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -52,11 +52,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
52 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"52 className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"
53 data-composer-context-control53 data-composer-context-control
54 >54 >
55 {activeEnvironment?.isPrimary ? (55 <EnvironmentMachineIcon
56 <MonitorIcon className="size-3 shrink-0" />56 kind={activeEnvironment?.machine ?? "server"}
57 ) : (57 className="size-3 shrink-0"
58 <CloudIcon className="size-3 shrink-0" />58 />
59 )}
60 <span59 <span
61 data-composer-label60 data-composer-label
62 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"61 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
...@@ -86,11 +85,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -86,11 +85,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
86 aria-label="Run on"85 aria-label="Run on"
87 data-composer-context-control86 data-composer-context-control
88 >87 >
89 {activeEnvironment?.isPrimary ? (88 <EnvironmentMachineIcon
90 <MonitorIcon className="size-3 shrink-0" />89 kind={activeEnvironment?.machine ?? "server"}
91 ) : (90 className="size-3 shrink-0"
92 <CloudIcon className="size-3 shrink-0" />91 />
93 )}
94 <span92 <span
95 data-composer-label93 data-composer-label
96 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"94 className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
...@@ -109,11 +107,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -109,11 +107,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
109 {availableEnvironments.map((env) => (107 {availableEnvironments.map((env) => (
110 <SelectItem key={env.environmentId} value={env.environmentId}>108 <SelectItem key={env.environmentId} value={env.environmentId}>
111 <span className="inline-flex items-center gap-1.5">109 <span className="inline-flex items-center gap-1.5">
112 {env.isPrimary ? (110 <EnvironmentMachineIcon kind={env.machine} className="size-3" />
113 <MonitorIcon className="size-3" />
114 ) : (
115 <CloudIcon className="size-3" />
116 )}
117 {env.label}111 {env.label}
118 </span>112 </span>
119 </SelectItem>113 </SelectItem>
apps/web/src/components/CommandPalette.tsx+9-2
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 type SourceControlProviderKind,36 type SourceControlProviderKind,
37 type SourceControlRepositoryInfo,37 type SourceControlRepositoryInfo,
38 PRIMARY_LOCAL_ENVIRONMENT_ID,38 PRIMARY_LOCAL_ENVIRONMENT_ID,
39 resolveEnvironmentMachineKind,
39} from "@t3tools/contracts";40} from "@t3tools/contracts";
40import { useLocation, useNavigate, useParams } from "@tanstack/react-router";41import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
41import * as Option from "effect/Option";42import * as Option from "effect/Option";
...@@ -48,7 +49,6 @@ import {...@@ -48,7 +49,6 @@ import {
48 LinkIcon,49 LinkIcon,
49 MessageSquareIcon,50 MessageSquareIcon,
50 PaletteIcon,51 PaletteIcon,
51 ServerIcon,
52 SettingsIcon,52 SettingsIcon,
53 SquarePenIcon,53 SquarePenIcon,
54 TextSearchIcon,54 TextSearchIcon,
...@@ -145,6 +145,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";...@@ -145,6 +145,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
145import { CommandPaletteContent } from "./CommandPaletteContent";145import { CommandPaletteContent } from "./CommandPaletteContent";
146import { CommandPaletteResults } from "./CommandPaletteResults";146import { CommandPaletteResults } from "./CommandPaletteResults";
147import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";147import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
148import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
148import { ProjectFavicon } from "./ProjectFavicon";149import { ProjectFavicon } from "./ProjectFavicon";
149import { ProjectFilePicker } from "./files/ProjectFilePicker";150import { ProjectFilePicker } from "./files/ProjectFilePicker";
150import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";151import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
...@@ -738,6 +739,7 @@ function OpenCommandPaletteDialog(props: {...@@ -738,6 +739,7 @@ function OpenCommandPaletteDialog(props: {
738 : isLocal739 : isLocal
739 ? `${environment.label} (Local)`740 ? `${environment.label} (Local)`
740 : environment.label,741 : environment.label,
742 machine: resolveEnvironmentMachineKind(environment.serverConfig),
741 },743 },
742 ] as const;744 ] as const;
743 }),745 }),
...@@ -1119,12 +1121,17 @@ function OpenCommandPaletteDialog(props: {...@@ -1119,12 +1121,17 @@ function OpenCommandPaletteDialog(props: {
1119 const location = projectEnvironmentLocationById.get(project.environmentId) ?? {1121 const location = projectEnvironmentLocationById.get(project.environmentId) ?? {
1120 kind: "remote",1122 kind: "remote",
1121 label: "Remote",1123 label: "Remote",
1124 machine: "server" as const,
1122 };1125 };
1123 return (1126 return (
1124 <span className="flex min-w-0 items-center gap-1">1127 <span className="flex min-w-0 items-center gap-1">
1125 <span className="inline-flex min-w-0 items-center gap-1">1128 <span className="inline-flex min-w-0 items-center gap-1">
1126 {location.kind === "remote" ? (1129 {location.kind === "remote" ? (
1127 <ServerIcon aria-hidden className={COMMAND_PALETTE_META_ICON_CLASS} />1130 <EnvironmentMachineIcon
1131 aria-hidden
1132 kind={location.machine}
1133 className={COMMAND_PALETTE_META_ICON_CLASS}
1134 />
1128 ) : null}1135 ) : null}
1129 <span className="truncate">{location.label}</span>1136 <span className="truncate">{location.label}</span>
1130 </span>1137 </span>
apps/web/src/components/ThreadStatusIndicators.tsx+16-5
...@@ -4,12 +4,18 @@ import {...@@ -4,12 +4,18 @@ import {
4 scopeThreadRef,4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";5} from "@t3tools/client-runtime/environment";
6import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";6import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
7import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts";7import {
8 type EnvironmentId,
9 resolveEnvironmentMachineKind,
10 type ThreadLinkedPullRequest,
11 type VcsStatusResult,
12} from "@t3tools/contracts";
8import { Atom } from "effect/unstable/reactivity";13import { Atom } from "effect/unstable/reactivity";
9import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";14import { FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
10import { useMemo } from "react";15import { useMemo } from "react";
11import { appAtomRegistry } from "../rpc/atomRegistry";16import { appAtomRegistry } from "../rpc/atomRegistry";
12import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";17import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
18import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
13import { useProject } from "../state/entities";19import { useProject } from "../state/entities";
14import { useEnvironmentQuery } from "../state/query";20import { useEnvironmentQuery } from "../state/query";
15import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";21import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
...@@ -602,10 +608,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma...@@ -602,10 +608,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
602 });608 });
603 const environment = useEnvironment(thread.environmentId);609 const environment = useEnvironment(thread.environmentId);
604 const primaryEnvironmentId = usePrimaryEnvironmentId();610 const primaryEnvironmentId = usePrimaryEnvironmentId();
605 const isRemoteThread =611 // No primary (the hosted app) means every thread is remote, and the machine
606 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;612 // glyph is what tells the environments apart.
613 const isRemoteThread = thread.environmentId !== primaryEnvironmentId;
607 const remoteEnvLabel = environment?.label ?? null;614 const remoteEnvLabel = environment?.label ?? null;
608 const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;615 const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;
616 const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);
609 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);617 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
610618
611 if (!terminalStatus && !isRemoteThread) {619 if (!terminalStatus && !isRemoteThread) {
...@@ -642,7 +650,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma...@@ -642,7 +650,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
642 />650 />
643 }651 }
644 >652 >
645 <CloudIcon className="size-3 text-muted-foreground/60" />653 <EnvironmentMachineIcon
654 kind={remoteMachine}
655 className="size-3 text-muted-foreground/60"
656 />
646 </TooltipTrigger>657 </TooltipTrigger>
647 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>658 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
648 </Tooltip>659 </Tooltip>
apps/web/src/components/settings/ProviderSettingsPanel.tsx+9-20
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 ProviderDriverKind,12 ProviderDriverKind,
13 type ProviderInstanceConfig,13 type ProviderInstanceConfig,
14 type ProviderInstanceId,14 type ProviderInstanceId,
15 resolveEnvironmentMachineKind,
15 resolveProviderInstanceEnabled,16 resolveProviderInstanceEnabled,
16} from "@t3tools/contracts";17} from "@t3tools/contracts";
17import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";18import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
...@@ -23,22 +24,14 @@ import * as Arr from "effect/Array";...@@ -23,22 +24,14 @@ import * as Arr from "effect/Array";
23import * as Duration from "effect/Duration";24import * as Duration from "effect/Duration";
24import * as Equal from "effect/Equal";25import * as Equal from "effect/Equal";
25import * as Result from "effect/Result";26import * as Result from "effect/Result";
26import {27import { ChevronDownIcon, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
27 ChevronDownIcon,
28 CloudIcon,
29 LaptopIcon,
30 LoaderIcon,
31 MonitorIcon,
32 PlusIcon,
33 RefreshCwIcon,
34 TerminalIcon,
35} from "lucide-react";
36import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";28import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3729
38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";30import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
39import { isElectron } from "../../env";31import { isElectron } from "../../env";
40import { usePrimarySessionState } from "../../environments/primary";32import { usePrimarySessionState } from "../../environments/primary";
41import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";33import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
34import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
42import { cn } from "../../lib/utils";35import { cn } from "../../lib/utils";
43import { resolveAppModelSelectionState } from "../../modelSelection";36import { resolveAppModelSelectionState } from "../../modelSelection";
44import {37import {
...@@ -152,14 +145,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }...@@ -152,14 +145,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }
152 );145 );
153}146}
154147
155function providerEnvironmentIcon(environment: EnvironmentPresentation) {
156 if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon;
157 if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon;
158 if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon;
159 if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon;
160 return CloudIcon;
161}
162
163function providerEnvironmentDetail(environment: EnvironmentPresentation): string {148function providerEnvironmentDetail(environment: EnvironmentPresentation): string {
164 if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device";149 if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device";
165 if (environment.relayManaged) return "T3 Connect";150 if (environment.relayManaged) return "T3 Connect";
...@@ -259,7 +244,7 @@ function ProviderSettingsPanelContent() {...@@ -259,7 +244,7 @@ function ProviderSettingsPanelContent() {
259 className="flex h-full w-max min-w-full border-b border-border/70 px-1"244 className="flex h-full w-max min-w-full border-b border-border/70 px-1"
260 >245 >
261 {options.map((environment) => {246 {options.map((environment) => {
262 const Icon = providerEnvironmentIcon(environment);247 const machine = resolveEnvironmentMachineKind(environment.serverConfig);
263 const selected = environment.environmentId === effectiveEnvironmentId;248 const selected = environment.environmentId === effectiveEnvironmentId;
264 const detail = providerEnvironmentDetail(environment);249 const detail = providerEnvironmentDetail(environment);
265 const statusText = connectionStatusText(environment.connection);250 const statusText = connectionStatusText(environment.connection);
...@@ -273,7 +258,11 @@ function ProviderSettingsPanelContent() {...@@ -273,7 +258,11 @@ function ProviderSettingsPanelContent() {
273 className={cn(providerSettingsTabClassName(selected), "gap-2 text-left")}258 className={cn(providerSettingsTabClassName(selected), "gap-2 text-left")}
274 onClick={() => setSelectedEnvironmentId(environment.environmentId)}259 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
275 >260 >
276 <Icon className="size-3.5 shrink-0" aria-hidden />261 <EnvironmentMachineIcon
262 kind={machine}
263 className="size-3.5 shrink-0"
264 aria-hidden
265 />
277 <span className="max-w-40 truncate">{environment.label}</span>266 <span className="max-w-40 truncate">{environment.label}</span>
278 {environment.connection.phase !== "connected" ? (267 {environment.connection.phase !== "connected" ? (
279 <ConnectionStatusDot268 <ConnectionStatusDot
docs/user/thread-sidebar.md+4-2
...@@ -59,8 +59,10 @@ When you are connected to more than one environment, every thread that lives som...@@ -59,8 +59,10 @@ When you are connected to more than one environment, every thread that lives som
59the machine you are on wears a small icon for that machine at the end of its row: a server, a cloud59the 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,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 a61where 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 server62glance. The same icon appears wherever an environment is named: the thread tooltip, the command
63filter, and the environment lists under **Settings → Connections**.63palette, the "Run on" picker, the pull request server filter, the provider settings device tabs,
64and the environment lists under **Settings → Connections**. On mobile it appears in the thread
65lists, the archive, the new-task environment picker, and the Environments and storage settings.
6466
65Servers pick the icon themselves from the hardware they run on. A Mac reports its model, a Linux67Servers 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 usable68machine reports its chassis type and whether it is a virtual machine, and anything without a usable