| author | |
| committer | |
| log | c1b2ed6504bbd57440d03c6a5af2736f33be7c65 |
| tree | 02842faf01a5feb74e76aa6d21da159633b7ee03 |
| parent | 0f5a1513e09fb41e0d0f86157aff64cc3657975d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>11 files changed, 153 insertions(+), 52 deletions(-)
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+34-13| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | import { | 7 | import { |
| 8 | type EnvironmentId, | 8 | type EnvironmentId, |
| 9 | type EnvironmentMachineKind, | 9 | type EnvironmentMachineKind, |
| 10 | type ExecutionEnvironmentDescriptor, | ||
| 10 | resolveEnvironmentMachineKind, | 11 | resolveEnvironmentMachineKind, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 12 | import { useAtomValue } from "@effect/atom-react"; | 13 | import { useAtomValue } from "@effect/atom-react"; |
| ... | @@ -99,6 +100,16 @@ function CloudEnvironmentRowsContent( | ... | @@ -99,6 +100,16 @@ function CloudEnvironmentRowsContent( |
| 99 | [controller], | 100 | [controller], |
| 100 | ); | 101 | ); |
| 101 | 102 | ||
| 103 | // The relay's health probe carries each server's descriptor, so a machine | ||
| 104 | // can wear its detected glyph before this device ever connects to it. | ||
| 105 | const discoveredDescriptors = new Map( | ||
| 106 | controller.relayEnvironments.flatMap((entry) => | ||
| 107 | entry.status?.descriptor === undefined | ||
| 108 | ? [] | ||
| 109 | : [[entry.environment.environmentId, entry.status.descriptor] as const], | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | |||
| 102 | const handleToggleCloudError = useCallback((environmentId: string) => { | 113 | const handleToggleCloudError = useCallback((environmentId: string) => { |
| 103 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); | 114 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); |
| 104 | }, []); | 115 | }, []); |
| ... | @@ -140,6 +151,7 @@ function CloudEnvironmentRowsContent( | ... | @@ -140,6 +151,7 @@ function CloudEnvironmentRowsContent( |
| 140 | <ConnectedCloudEnvironmentRow | 151 | <ConnectedCloudEnvironmentRow |
| 141 | key={environment.environmentId} | 152 | key={environment.environmentId} |
| 142 | environment={environment} | 153 | environment={environment} |
| 154 | descriptor={discoveredDescriptors.get(environment.environmentId)} | ||
| 143 | borderTop={index !== 0} | 155 | borderTop={index !== 0} |
| 144 | onSetEnabled={(enabled) => | 156 | onSetEnabled={(enabled) => |
| 145 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) | 157 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) |
| ... | @@ -210,6 +222,8 @@ function CloudEnvironmentRowsContent( | ... | @@ -210,6 +222,8 @@ function CloudEnvironmentRowsContent( |
| 210 | */ | 222 | */ |
| 211 | function ConnectedCloudEnvironmentRow(props: { | 223 | function ConnectedCloudEnvironmentRow(props: { |
| 212 | readonly environment: ConnectedEnvironmentSummary; | 224 | readonly environment: ConnectedEnvironmentSummary; |
| 225 | /** Discovery's view of the server, for the glyph before the first connection. */ | ||
| 226 | readonly descriptor: ExecutionEnvironmentDescriptor | undefined; | ||
| 213 | readonly borderTop: boolean; | 227 | readonly borderTop: boolean; |
| 214 | readonly errorExpanded: boolean; | 228 | readonly errorExpanded: boolean; |
| 215 | readonly onSetEnabled: (enabled: boolean) => void; | 229 | readonly onSetEnabled: (enabled: boolean) => void; |
| ... | @@ -233,7 +247,10 @@ function ConnectedCloudEnvironmentRow(props: { | ... | @@ -233,7 +247,10 @@ function ConnectedCloudEnvironmentRow(props: { |
| 233 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} | 247 | connectionState={enabled || unsupported ? props.environment.connectionState : "available"} |
| 234 | errorExpanded={props.errorExpanded} | 248 | errorExpanded={props.errorExpanded} |
| 235 | label={props.environment.environmentLabel} | 249 | label={props.environment.environmentLabel} |
| 236 | machine={resolveEnvironmentMachineKind(serverConfig)} | 250 | machine={resolveEnvironmentMachineKind( |
| 251 | serverConfig ?? | ||
| 252 | (props.descriptor === undefined ? null : { environment: props.descriptor }), | ||
| 253 | )} | ||
| 237 | onValueChange={props.onSetEnabled} | 254 | onValueChange={props.onSetEnabled} |
| 238 | onToggleError={props.onToggleError} | 255 | onToggleError={props.onToggleError} |
| 239 | disabled={unsupported} | 256 | disabled={unsupported} |
| ... | @@ -266,6 +283,11 @@ function CloudEnvironmentRow(props: { | ... | @@ -266,6 +283,11 @@ function CloudEnvironmentRow(props: { |
| 266 | connectionState={presentation.connectionState} | 283 | connectionState={presentation.connectionState} |
| 267 | errorExpanded={props.errorExpanded} | 284 | errorExpanded={props.errorExpanded} |
| 268 | label={props.environment.environment.label} | 285 | label={props.environment.environment.label} |
| 286 | machine={resolveEnvironmentMachineKind( | ||
| 287 | props.environment.status?.descriptor === undefined | ||
| 288 | ? null | ||
| 289 | : { environment: props.environment.status.descriptor }, | ||
| 290 | )} | ||
| 269 | onValueChange={(enabled) => { | 291 | onValueChange={(enabled) => { |
| 270 | if (enabled) { | 292 | if (enabled) { |
| 271 | props.onConnect(); | 293 | props.onConnect(); |
| ... | @@ -287,8 +309,7 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -287,8 +309,7 @@ function CloudEnvironmentRowShell(props: { |
| 287 | readonly disabled?: boolean; | 309 | readonly disabled?: boolean; |
| 288 | readonly errorExpanded: boolean; | 310 | readonly errorExpanded: boolean; |
| 289 | readonly label: string; | 311 | readonly label: string; |
| 290 | /** Absent for environments the relay lists but this device has not connected to. */ | 312 | readonly machine: EnvironmentMachineKind; |
| 291 | readonly machine?: EnvironmentMachineKind; | ||
| 292 | readonly onToggleError: () => void; | 313 | readonly onToggleError: () => void; |
| 293 | readonly onValueChange: (enabled: boolean) => void; | 314 | readonly onValueChange: (enabled: boolean) => void; |
| 294 | readonly statusText?: string; | 315 | readonly statusText?: string; |
| ... | @@ -304,9 +325,11 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -304,9 +325,11 @@ function CloudEnvironmentRowShell(props: { |
| 304 | error: props.connectionError, | 325 | error: props.connectionError, |
| 305 | traceId: props.connectionErrorTraceId, | 326 | traceId: props.connectionErrorTraceId, |
| 306 | }); | 327 | }); |
| 307 | const statusClassName = props.connectionError | 328 | // Unsupported is a compatibility note, not a failure, so it stays muted. |
| 308 | ? "text-danger-foreground" | 329 | const statusClassName = |
| 309 | : "text-foreground-muted"; | 330 | props.connectionError && props.connectionState !== "unsupported" |
| 331 | ? "text-danger-foreground" | ||
| 332 | : "text-foreground-muted"; | ||
| 310 | const [errorMeasurement, setErrorMeasurement] = useState<{ | 333 | const [errorMeasurement, setErrorMeasurement] = useState<{ |
| 311 | readonly text: string; | 334 | readonly text: string; |
| 312 | readonly lineCount: number; | 335 | readonly lineCount: number; |
| ... | @@ -344,13 +367,11 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -344,13 +367,11 @@ function CloudEnvironmentRowShell(props: { |
| 344 | <View className="min-w-0 flex-1 gap-0.5"> | 367 | <View className="min-w-0 flex-1 gap-0.5"> |
| 345 | <View className="min-w-0 flex-row items-center gap-2"> | 368 | <View className="min-w-0 flex-row items-center gap-2"> |
| 346 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> | 369 | <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} /> |
| 347 | {props.machine ? ( | 370 | <EnvironmentMachineSymbol |
| 348 | <EnvironmentMachineSymbol | 371 | kind={props.machine} |
| 349 | kind={props.machine} | 372 | size={14} |
| 350 | size={14} | 373 | tintColorClassName="accent-foreground-muted" |
| 351 | tintColorClassName="accent-foreground-muted" | 374 | /> |
| 352 | /> | ||
| 353 | ) : null} | ||
| 354 | <Text | 375 | <Text |
| 355 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" | 376 | className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground" |
| 356 | numberOfLines={1} | 377 | numberOfLines={1} |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+2-2| ... | @@ -50,8 +50,8 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -50,8 +50,8 @@ export function ConnectionEnvironmentRow(props: { |
| 50 | const enabled = props.environment.isEnabled && !unsupported; | 50 | const enabled = props.environment.isEnabled && !unsupported; |
| 51 | const statusLabel = connectionStatusLabel(props.environment); | 51 | const statusLabel = connectionStatusLabel(props.environment); |
| 52 | const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; | 52 | const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; |
| 53 | const hasConnectionFailure = | 53 | // Unsupported is a compatibility note, not a failure, so it stays muted. |
| 54 | (enabled || unsupported) && props.environment.connectionError !== null; | 54 | const hasConnectionFailure = enabled && props.environment.connectionError !== null; |
| 55 | const isRetrying = | 55 | const isRetrying = |
| 56 | enabled && | 56 | enabled && |
| 57 | (props.environment.connectionState === "connecting" || | 57 | (props.environment.connectionState === "connecting" || |
apps/mobile/src/features/connection/ConnectionStatusDot.tsx+3-1| ... | @@ -18,7 +18,10 @@ function statusDotTone(state: ConnectionStatusDotState): { | ... | @@ -18,7 +18,10 @@ function statusDotTone(state: ConnectionStatusDotState): { |
| 18 | readonly haloColor: string; | 18 | readonly haloColor: string; |
| 19 | } { | 19 | } { |
| 20 | switch (state) { | 20 | switch (state) { |
| 21 | // Unsupported is not a failure: the machine is fine, this build just | ||
| 22 | // cannot talk to it, so it wears the same neutral dot as "available". | ||
| 21 | case "available": | 23 | case "available": |
| 24 | case "unsupported": | ||
| 22 | return { | 25 | return { |
| 23 | dotColor: "#9ca3af", | 26 | dotColor: "#9ca3af", |
| 24 | haloColor: "rgba(156,163,175,0.42)", | 27 | haloColor: "rgba(156,163,175,0.42)", |
| ... | @@ -35,7 +38,6 @@ function statusDotTone(state: ConnectionStatusDotState): { | ... | @@ -35,7 +38,6 @@ function statusDotTone(state: ConnectionStatusDotState): { |
| 35 | haloColor: "rgba(245,158,11,0.5)", | 38 | haloColor: "rgba(245,158,11,0.5)", |
| 36 | }; | 39 | }; |
| 37 | case "offline": | 40 | case "offline": |
| 38 | case "unsupported": | ||
| 39 | case "error": | 41 | case "error": |
| 40 | return { | 42 | return { |
| 41 | dotColor: "#ef4444", | 43 | dotColor: "#ef4444", |
apps/mobile/src/features/connection/connectionTone.ts+2-2| ... | @@ -24,8 +24,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { | ... | @@ -24,8 +24,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { |
| 24 | case "unsupported": | 24 | case "unsupported": |
| 25 | return { | 25 | return { |
| 26 | label: "Client not supported", | 26 | label: "Client not supported", |
| 27 | pillClassName: "bg-danger", | 27 | pillClassName: "bg-subtle", |
| 28 | textClassName: "text-danger-foreground", | 28 | textClassName: "text-foreground-secondary", |
| 29 | }; | 29 | }; |
| 30 | case "error": | 30 | case "error": |
| 31 | return { | 31 | return { |
apps/web/src/components/ConnectionStatusDot.tsx-1| ... | @@ -11,7 +11,6 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): | ... | @@ -11,7 +11,6 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): |
| 11 | case "connecting": | 11 | case "connecting": |
| 12 | case "reconnecting": | 12 | case "reconnecting": |
| 13 | return "bg-warning"; | 13 | return "bg-warning"; |
| 14 | case "unsupported": | ||
| 15 | case "error": | 14 | case "error": |
| 16 | return "bg-destructive"; | 15 | return "bg-destructive"; |
| 17 | default: | 16 | default: |
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+68-23| ... | @@ -9,7 +9,11 @@ import { | ... | @@ -9,7 +9,11 @@ import { |
| 9 | isAtomCommandInterrupted, | 9 | isAtomCommandInterrupted, |
| 10 | squashAtomCommandFailure, | 10 | squashAtomCommandFailure, |
| 11 | } from "@t3tools/client-runtime/state/runtime"; | 11 | } from "@t3tools/client-runtime/state/runtime"; |
| 12 | import type { EnvironmentId } from "@t3tools/contracts"; | 12 | import { |
| 13 | type EnvironmentId, | ||
| 14 | resolveEnvironmentMachineKind, | ||
| 15 | type ServerConfig, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 13 | import type { | 17 | import type { |
| 14 | RelayClientEnvironmentRecord, | 18 | RelayClientEnvironmentRecord, |
| 15 | RelayEnvironmentStatusResponse, | 19 | RelayEnvironmentStatusResponse, |
| ... | @@ -23,6 +27,7 @@ import { relayEnvironmentDiscovery } from "~/state/relay"; | ... | @@ -23,6 +27,7 @@ import { relayEnvironmentDiscovery } from "~/state/relay"; |
| 23 | import { useRelayEnvironmentDiscovery } from "~/state/environments"; | 27 | import { useRelayEnvironmentDiscovery } from "~/state/environments"; |
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | 28 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | 29 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 30 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 26 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; | 31 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; |
| 27 | import { Checkbox } from "../ui/checkbox"; | 32 | import { Checkbox } from "../ui/checkbox"; |
| 28 | import { Button } from "../ui/button"; | 33 | import { Button } from "../ui/button"; |
| ... | @@ -43,6 +48,8 @@ function discoveredCompatibilityError( | ... | @@ -43,6 +48,8 @@ function discoveredCompatibilityError( |
| 43 | export interface SavedCloudEnvironmentConnection { | 48 | export interface SavedCloudEnvironmentConnection { |
| 44 | readonly environmentId: EnvironmentId; | 49 | readonly environmentId: EnvironmentId; |
| 45 | readonly connection: EnvironmentConnectionPresentation; | 50 | readonly connection: EnvironmentConnectionPresentation; |
| 51 | /** Present once connected; carries the user's icon override. */ | ||
| 52 | readonly serverConfig?: ServerConfig | null; | ||
| 46 | } | 53 | } |
| 47 | 54 | ||
| 48 | function RemoteEnvironmentRowsSkeleton() { | 55 | function RemoteEnvironmentRowsSkeleton() { |
| ... | @@ -296,15 +303,25 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -296,15 +303,25 @@ export function CloudEnvironmentConnectRows({ |
| 296 | const compatibilityError = discoveredCompatibilityError(status); | 303 | const compatibilityError = discoveredCompatibilityError(status); |
| 297 | const unsupported = | 304 | const unsupported = |
| 298 | compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported"; | 305 | compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported"; |
| 306 | const unsupportedDetail = | ||
| 307 | compatibilityError?.message ?? savedEnvironment?.connection.error ?? null; | ||
| 299 | const savedConnection = unsupported | 308 | const savedConnection = unsupported |
| 300 | ? presentSavedCloudEnvironmentConnection({ | 309 | ? presentSavedCloudEnvironmentConnection({ |
| 301 | phase: "unsupported", | 310 | phase: "unsupported", |
| 302 | error: compatibilityError?.message ?? savedEnvironment?.connection.error ?? null, | 311 | error: unsupportedDetail, |
| 303 | traceId: null, | 312 | traceId: null, |
| 304 | }) | 313 | }) |
| 305 | : savedEnvironment | 314 | : savedEnvironment |
| 306 | ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) | 315 | ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) |
| 307 | : null; | 316 | : null; |
| 317 | // A connected machine's own config (with the user's icon pick) wins. Before | ||
| 318 | // that, the relay's health probe already carries the server's descriptor, so | ||
| 319 | // a machine can wear its detected glyph before this device ever connects. | ||
| 320 | const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor; | ||
| 321 | const machineKind = resolveEnvironmentMachineKind( | ||
| 322 | savedEnvironment?.serverConfig ?? | ||
| 323 | (descriptor === undefined ? null : { environment: descriptor }), | ||
| 324 | ); | ||
| 308 | const dotClassName = savedConnection | 325 | const dotClassName = savedConnection |
| 309 | ? savedConnection.tone === "connected" | 326 | ? savedConnection.tone === "connected" |
| 310 | ? "bg-success" | 327 | ? "bg-success" |
| ... | @@ -320,16 +337,19 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -320,16 +337,19 @@ export function CloudEnvironmentConnectRows({ |
| 320 | : availability === "checking" | 337 | : availability === "checking" |
| 321 | ? "bg-warning" | 338 | ? "bg-warning" |
| 322 | : "bg-muted-foreground/35"; | 339 | : "bg-muted-foreground/35"; |
| 323 | const statusText = savedConnection | 340 | const statusText = |
| 324 | ? savedConnection.statusText | 341 | unsupported && !savedEnvironment |
| 325 | : availability === "online" | 342 | ? "T3 Connect · Not added · Client not supported" |
| 326 | ? "T3 Connect · Not added · Relay online" | 343 | : savedConnection |
| 327 | : availability === "offline" | 344 | ? savedConnection.statusText |
| 328 | ? "T3 Connect · Not added · Relay offline" | 345 | : availability === "online" |
| 329 | : availability === "checking" | 346 | ? "T3 Connect · Not added · Relay online" |
| 330 | ? "T3 Connect · Not added · Checking relay status…" | 347 | : availability === "offline" |
| 331 | : (Option.getOrNull(error)?.message ?? | 348 | ? "T3 Connect · Not added · Relay offline" |
| 332 | "T3 Connect · Not added · Relay status unavailable"); | 349 | : availability === "checking" |
| 350 | ? "T3 Connect · Not added · Checking relay status…" | ||
| 351 | : (Option.getOrNull(error)?.message ?? | ||
| 352 | "T3 Connect · Not added · Relay status unavailable"); | ||
| 333 | if (selection) { | 353 | if (selection) { |
| 334 | return ( | 354 | return ( |
| 335 | <label | 355 | <label |
| ... | @@ -348,6 +368,11 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -348,6 +368,11 @@ export function CloudEnvironmentConnectRows({ |
| 348 | } | 368 | } |
| 349 | }} | 369 | }} |
| 350 | /> | 370 | /> |
| 371 | <EnvironmentMachineIcon | ||
| 372 | aria-hidden | ||
| 373 | kind={machineKind} | ||
| 374 | className="size-4 shrink-0 text-muted-foreground" | ||
| 375 | /> | ||
| 351 | <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span> | 376 | <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span> |
| 352 | <Tooltip> | 377 | <Tooltip> |
| 353 | <TooltipTrigger | 378 | <TooltipTrigger |
| ... | @@ -368,7 +393,9 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -368,7 +393,9 @@ export function CloudEnvironmentConnectRows({ |
| 368 | ? "Unavailable" | 393 | ? "Unavailable" |
| 369 | : "Checking…"))} | 394 | : "Checking…"))} |
| 370 | </TooltipTrigger> | 395 | </TooltipTrigger> |
| 371 | <TooltipPopup className="max-w-80 break-words">{statusText}</TooltipPopup> | 396 | <TooltipPopup className="max-w-80 break-words"> |
| 397 | {unsupportedDetail ?? statusText} | ||
| 398 | </TooltipPopup> | ||
| 372 | </Tooltip> | 399 | </Tooltip> |
| 373 | </label> | 400 | </label> |
| 374 | ); | 401 | ); |
| ... | @@ -387,17 +414,24 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -387,17 +414,24 @@ export function CloudEnvironmentConnectRows({ |
| 387 | : null | 414 | : null |
| 388 | } | 415 | } |
| 389 | tooltipText={ | 416 | tooltipText={ |
| 390 | savedConnection | 417 | unsupportedDetail !== null |
| 391 | ? savedConnection.statusText | 418 | ? unsupportedDetail |
| 392 | : availability === "online" | 419 | : savedConnection |
| 393 | ? "Relay online" | 420 | ? savedConnection.statusText |
| 394 | : availability === "offline" | 421 | : availability === "online" |
| 395 | ? "Relay offline" | 422 | ? "Relay online" |
| 396 | : availability === "checking" | 423 | : availability === "offline" |
| 397 | ? "Checking relay status" | 424 | ? "Relay offline" |
| 398 | : (Option.getOrNull(error)?.message ?? "Relay status unavailable") | 425 | : availability === "checking" |
| 426 | ? "Checking relay status" | ||
| 427 | : (Option.getOrNull(error)?.message ?? "Relay status unavailable") | ||
| 399 | } | 428 | } |
| 400 | /> | 429 | /> |
| 430 | <EnvironmentMachineIcon | ||
| 431 | aria-hidden | ||
| 432 | kind={machineKind} | ||
| 433 | className="size-4 shrink-0 text-muted-foreground" | ||
| 434 | /> | ||
| 401 | <p className="truncate text-sm font-medium">{environment.label}</p> | 435 | <p className="truncate text-sm font-medium">{environment.label}</p> |
| 402 | </div> | 436 | </div> |
| 403 | <p | 437 | <p |
| ... | @@ -413,7 +447,18 @@ export function CloudEnvironmentConnectRows({ | ... | @@ -413,7 +447,18 @@ export function CloudEnvironmentConnectRows({ |
| 413 | {statusText} | 447 | {statusText} |
| 414 | </p> | 448 | </p> |
| 415 | </div> | 449 | </div> |
| 416 | {savedConnection ? ( | 450 | {unsupported && !savedEnvironment ? ( |
| 451 | <Tooltip> | ||
| 452 | <TooltipTrigger render={<span className="inline-flex" tabIndex={0} />}> | ||
| 453 | <Button size="sm" disabled> | ||
| 454 | Add | ||
| 455 | </Button> | ||
| 456 | </TooltipTrigger> | ||
| 457 | <TooltipPopup className="max-w-80 break-words"> | ||
| 458 | {unsupportedDetail ?? "Client not supported"} | ||
| 459 | </TooltipPopup> | ||
| 460 | </Tooltip> | ||
| 461 | ) : savedConnection ? ( | ||
| 417 | <Button size="sm" variant="outline" disabled> | 462 | <Button size="sm" variant="outline" disabled> |
| 418 | {savedConnection.buttonLabel} | 463 | {savedConnection.buttonLabel} |
| 419 | </Button> | 464 | </Button> |
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts+1| ... | @@ -40,6 +40,7 @@ describe("saved cloud environment connection presentation", () => { | ... | @@ -40,6 +40,7 @@ describe("saved cloud environment connection presentation", () => { |
| 40 | 40 | ||
| 41 | it.each([ | 41 | it.each([ |
| 42 | ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"], | 42 | ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"], |
| 43 | ["unsupported", "Client not supported", "Client not supported", "idle"], | ||
| 43 | ["offline", "Offline", "Offline", "idle"], | 44 | ["offline", "Offline", "Offline", "idle"], |
| 44 | ["available", "Not connected", "Available", "idle"], | 45 | ["available", "Not connected", "Available", "idle"], |
| 45 | ] as const)( | 46 | ] as const)( |
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts+7-2| ... | @@ -36,11 +36,16 @@ export function presentSavedCloudEnvironmentConnection( | ... | @@ -36,11 +36,16 @@ export function presentSavedCloudEnvironmentConnection( |
| 36 | statusText: connectionStatusText(connection), | 36 | statusText: connectionStatusText(connection), |
| 37 | tone: "connecting", | 37 | tone: "connecting", |
| 38 | }; | 38 | }; |
| 39 | // Not a failure: the machine is fine, this build just cannot talk to it. | ||
| 39 | case "unsupported": | 40 | case "unsupported": |
| 41 | return { | ||
| 42 | buttonLabel: "Client not supported", | ||
| 43 | statusText: connectionStatusText(connection), | ||
| 44 | tone: "idle", | ||
| 45 | }; | ||
| 40 | case "error": | 46 | case "error": |
| 41 | return { | 47 | return { |
| 42 | buttonLabel: | 48 | buttonLabel: "Connection failed", |
| 43 | connection.phase === "unsupported" ? "Client not supported" : "Connection failed", | ||
| 44 | statusText: connectionStatusText(connection), | 49 | statusText: connectionStatusText(connection), |
| 45 | tone: "error", | 50 | tone: "error", |
| 46 | }; | 51 | }; |
apps/web/src/components/settings/ConnectionsSettings.tsx+20-3| ... | @@ -161,6 +161,7 @@ import { | ... | @@ -161,6 +161,7 @@ import { |
| 161 | type EnvironmentPresentation, | 161 | type EnvironmentPresentation, |
| 162 | useEnvironments, | 162 | useEnvironments, |
| 163 | usePrimaryEnvironment, | 163 | usePrimaryEnvironment, |
| 164 | useRelayEnvironmentDiscovery, | ||
| 164 | } from "~/state/environments"; | 165 | } from "~/state/environments"; |
| 165 | import { requestConfirmDialog } from "~/confirmDialog"; | 166 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 166 | import { useAtomCommand } from "../../state/use-atom-command"; | 167 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -1457,8 +1458,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): { | ... | @@ -1457,8 +1458,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): { |
| 1457 | text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", | 1458 | text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting", |
| 1458 | tone: "error", | 1459 | tone: "error", |
| 1459 | }; | 1460 | }; |
| 1461 | // Not a failure: the machine is fine, this build just cannot talk to it. | ||
| 1460 | case "unsupported": | 1462 | case "unsupported": |
| 1461 | return { text: "Client not supported", tone: "error" }; | 1463 | return { text: "Client not supported", tone: "muted" }; |
| 1462 | case "error": | 1464 | case "error": |
| 1463 | return { | 1465 | return { |
| 1464 | text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", | 1466 | text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed", |
| ... | @@ -1519,6 +1521,17 @@ function SavedBackendListRow({ | ... | @@ -1519,6 +1521,17 @@ function SavedBackendListRow({ |
| 1519 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; | 1521 | serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; |
| 1520 | const status = savedBackendStatus(environment); | 1522 | const status = savedBackendStatus(environment); |
| 1521 | const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; | 1523 | const serverVersion = environment.serverConfig?.environment.serverVersion ?? null; |
| 1524 | // A saved T3 Connect machine this device has never reached (unsupported, | ||
| 1525 | // or not yet connected) still has a descriptor from relay discovery, so | ||
| 1526 | // it can wear its detected glyph instead of the generic server. | ||
| 1527 | const relayDiscovery = useRelayEnvironmentDiscovery(); | ||
| 1528 | const discoveredDescriptor = Option.getOrNull( | ||
| 1529 | relayDiscovery.environments.get(environmentId)?.status ?? Option.none(), | ||
| 1530 | )?.descriptor; | ||
| 1531 | const machineKind = resolveEnvironmentMachineKind( | ||
| 1532 | environment.serverConfig ?? | ||
| 1533 | (discoveredDescriptor === undefined ? null : { environment: discoveredDescriptor }), | ||
| 1534 | ); | ||
| 1522 | const subtitleText = [ | 1535 | const subtitleText = [ |
| 1523 | environmentTransportLabel(environment), | 1536 | environmentTransportLabel(environment), |
| 1524 | resumingServerUpdate ? "Restarting" : status.text, | 1537 | resumingServerUpdate ? "Restarting" : status.text, |
| ... | @@ -1537,7 +1550,7 @@ function SavedBackendListRow({ | ... | @@ -1537,7 +1550,7 @@ function SavedBackendListRow({ |
| 1537 | 1550 | ||
| 1538 | return ( | 1551 | return ( |
| 1539 | <EnvironmentRow | 1552 | <EnvironmentRow |
| 1540 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | 1553 | kind={machineKind} |
| 1541 | label={environment.label} | 1554 | label={environment.label} |
| 1542 | dimmed={!enabled} | 1555 | dimmed={!enabled} |
| 1543 | subtitle={ | 1556 | subtitle={ |
| ... | @@ -1558,7 +1571,11 @@ function SavedBackendListRow({ | ... | @@ -1558,7 +1571,11 @@ function SavedBackendListRow({ |
| 1558 | {subtitleText} | 1571 | {subtitleText} |
| 1559 | </TooltipTrigger> | 1572 | </TooltipTrigger> |
| 1560 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> | 1573 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> |
| 1561 | {enabled || unsupported ? connectionStatusText(environment.connection) : "Switched off"} | 1574 | {unsupported && environment.connection.error |
| 1575 | ? environment.connection.error | ||
| 1576 | : enabled | ||
| 1577 | ? connectionStatusText(environment.connection) | ||
| 1578 | : "Switched off"} | ||
| 1562 | {versionMismatch | 1579 | {versionMismatch |
| 1563 | ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` | 1580 | ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` |
| 1564 | : ""} | 1581 | : ""} |
packages/contracts/src/server.test.ts+6| ... | @@ -207,6 +207,12 @@ describe("resolveEnvironmentMachineKind", () => { | ... | @@ -207,6 +207,12 @@ describe("resolveEnvironmentMachineKind", () => { |
| 207 | ).toBe("mac-mini"); | 207 | ).toBe("mac-mini"); |
| 208 | }); | 208 | }); |
| 209 | 209 | ||
| 210 | it("uses detection from a bare descriptor before connecting", () => { | ||
| 211 | expect(resolveEnvironmentMachineKind({ environment: descriptor({ machine: "laptop" }) })).toBe( | ||
| 212 | "laptop", | ||
| 213 | ); | ||
| 214 | }); | ||
| 215 | |||
| 210 | it("falls back to a server for older servers and before connect", () => { | 216 | it("falls back to a server for older servers and before connect", () => { |
| 211 | expect( | 217 | expect( |
| 212 | resolveEnvironmentMachineKind({ | 218 | resolveEnvironmentMachineKind({ |
packages/contracts/src/server.ts+10-5| ... | @@ -598,14 +598,19 @@ export type ServerConfig = typeof ServerConfig.Type; | ... | @@ -598,14 +598,19 @@ export type ServerConfig = typeof ServerConfig.Type; |
| 598 | 598 | ||
| 599 | /** | 599 | /** |
| 600 | * The machine an environment should be drawn as: the user's pick, else what | 600 | * The machine an environment should be drawn as: the user's pick, else what |
| 601 | * the server detected, else a generic server. A null config (not connected | 601 | * the server detected, else a generic server. Settings only exist once |
| 602 | * yet, or an older server) resolves to the same generic so rows never | 602 | * connected; a descriptor alone (relay discovery, before any connection) |
| 603 | * flicker between glyphs. | 603 | * still yields the detected kind. A null config (nothing known yet, or an |
| 604 | * older server) resolves to the same generic so rows never flicker between | ||
| 605 | * glyphs. | ||
| 604 | */ | 606 | */ |
| 605 | export function resolveEnvironmentMachineKind( | 607 | export function resolveEnvironmentMachineKind( |
| 606 | config: Pick<ServerConfig, "environment" | "settings"> | null, | 608 | config: { |
| 609 | readonly environment: Pick<ExecutionEnvironmentDescriptor, "platform">; | ||
| 610 | readonly settings?: Pick<ServerSettings, "environmentIcon">; | ||
| 611 | } | null, | ||
| 607 | ): EnvironmentMachineKind { | 612 | ): EnvironmentMachineKind { |
| 608 | return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server"; | 613 | return config?.settings?.environmentIcon ?? config?.environment.platform.machine ?? "server"; |
| 609 | } | 614 | } |
| 610 | 615 | ||
| 611 | const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ | 616 | const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ |