authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-04 09:51:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 12:51:06-04:00
log8357eef14cbd5ed063e4a64404c33d1bfbc78446
tree887a20f3363d69f0949dcae6c2a063feade68b20
parent8ccb933a8aae461b616b199ab287164c3311a755
signature Signed by PGP key B5690EEEBB952194

fix(web): match provider settings layout for disconnected devices (#9619)


4 files changed, 151 insertions(+), 66 deletions(-)

apps/web/src/components/settings/DiagnosticsSettings.tsx+1-37
...@@ -38,6 +38,7 @@ import { Button } from "../ui/button";...@@ -38,6 +38,7 @@ import { Button } from "../ui/button";
38import { ScrollArea } from "../ui/scroll-area";38import { ScrollArea } from "../ui/scroll-area";
39import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";39import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
40import { toastManager } from "../ui/toast";40import { toastManager } from "../ui/toast";
41import { ExpandableText } from "./ExpandableText";
41import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";42import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
42import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";43import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
43import { useAtomCommand } from "../../state/use-atom-command";44import { useAtomCommand } from "../../state/use-atom-command";
...@@ -161,43 +162,6 @@ function EmptyRows({ label }: { label: string }) {...@@ -161,43 +162,6 @@ function EmptyRows({ label }: { label: string }) {
161 return <div className="px-4 py-4 text-xs text-muted-foreground sm:px-5">{label}</div>;162 return <div className="px-4 py-4 text-xs text-muted-foreground sm:px-5">{label}</div>;
162}163}
163164
164function ExpandableText({
165 text,
166 className,
167 collapsedClassName = "line-clamp-3",
168 expandLabel = "Show full error",
169}: {
170 text: string;
171 className?: string;
172 collapsedClassName?: string;
173 expandLabel?: string;
174}) {
175 const [expanded, setExpanded] = useState(false);
176 const canExpand = text.length > 180 || text.includes("\n");
177
178 return (
179 <div className={cn("min-w-0", className)}>
180 <div
181 className={cn(
182 "whitespace-pre-wrap break-words",
183 !expanded && canExpand ? collapsedClassName : null,
184 )}
185 >
186 {text}
187 </div>
188 {canExpand ? (
189 <button
190 type="button"
191 className="cursor-pointer mt-1 text-[11px] font-medium text-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
192 onClick={() => setExpanded((value) => !value)}
193 >
194 {expanded ? "Show less" : expandLabel}
195 </button>
196 ) : null}
197 </div>
198 );
199}
200
201function DiagnosticsTable({165function DiagnosticsTable({
202 headers,166 headers,
203 children,167 children,
apps/web/src/components/settings/ExpandableText.tsx created+44
...@@ -0,0 +1,44 @@
1import { useState } from "react";
2
3import { cn } from "../../lib/utils";
4
5/**
6 * Long error text clamped to a few lines with a toggle to reveal the rest.
7 * Short single-line text renders as-is without the toggle.
8 */
9export function ExpandableText({
10 text,
11 className,
12 collapsedClassName = "line-clamp-3",
13 expandLabel = "Show full error",
14}: {
15 text: string;
16 className?: string;
17 collapsedClassName?: string;
18 expandLabel?: string;
19}) {
20 const [expanded, setExpanded] = useState(false);
21 const canExpand = text.length > 180 || text.includes("\n");
22
23 return (
24 <div className={cn("min-w-0", className)}>
25 <div
26 className={cn(
27 "whitespace-pre-wrap break-words",
28 !expanded && canExpand ? collapsedClassName : null,
29 )}
30 >
31 {text}
32 </div>
33 {canExpand ? (
34 <button
35 type="button"
36 className="cursor-pointer mt-1 text-[11px] font-medium text-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
37 onClick={() => setExpanded((value) => !value)}
38 >
39 {expanded ? "Show less" : expandLabel}
40 </button>
41 ) : null}
42 </div>
43 );
44}
apps/web/src/components/settings/ProviderInstanceCard.tsx+5-1
...@@ -607,7 +607,11 @@ export function ProviderInstanceCard({...@@ -607,7 +607,11 @@ export function ProviderInstanceCard({
607 {instanceId}607 {instanceId}
608 </code>608 </code>
609 ) : null}609 ) : null}
610 {versionCodeNode}610 {versionLabel ? (
611 <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground">
612 {versionLabel}
613 </code>
614 ) : null}
611 {versionAdvisory ? (615 {versionAdvisory ? (
612 <span role="img" aria-label="Update available" className="inline-flex shrink-0">616 <span role="img" aria-label="Update available" className="inline-flex shrink-0">
613 <ArrowUpCircleIcon className="size-3.5 text-muted-foreground" />617 <ArrowUpCircleIcon className="size-3.5 text-muted-foreground" />
apps/web/src/components/settings/ProviderSettingsPanel.tsx+101-28
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";2import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
3import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";3import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
4import {4import {
5 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
...@@ -56,6 +56,14 @@ import {...@@ -56,6 +56,14 @@ import {
56 type ProviderUpdateCandidate,56 type ProviderUpdateCandidate,
57} from "../ProviderUpdateLaunchNotification.logic";57} from "../ProviderUpdateLaunchNotification.logic";
58import { Button } from "../ui/button";58import { Button } from "../ui/button";
59import {
60 Empty,
61 EmptyContent,
62 EmptyDescription,
63 EmptyHeader,
64 EmptyMedia,
65 EmptyTitle,
66} from "../ui/empty";
59import {67import {
60 NumberField,68 NumberField,
61 NumberFieldDecrement,69 NumberFieldDecrement,
...@@ -67,6 +75,7 @@ import { ScrollArea } from "../ui/scroll-area";...@@ -67,6 +75,7 @@ import { ScrollArea } from "../ui/scroll-area";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";75import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
68import { stackedThreadToast, toastManager } from "../ui/toast";76import { stackedThreadToast, toastManager } from "../ui/toast";
69import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";77import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
78import { ExpandableText } from "./ExpandableText";
70import { ProviderInstanceCard } from "./ProviderInstanceCard";79import { ProviderInstanceCard } from "./ProviderInstanceCard";
71import { UsageProviderSettings } from "./UsageProviderSettings";80import { UsageProviderSettings } from "./UsageProviderSettings";
72import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";81import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
...@@ -159,7 +168,52 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string...@@ -159,7 +168,52 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string
159 return environment.displayUrl ?? "Remote device";168 return environment.displayUrl ?? "Remote device";
160}169}
161170
162function EnvironmentUnavailableRow({171const providerCardClassName = "rounded-xl border border-border/60 bg-card/40 shadow-xs/5";
172// Shared by the editor grid and the placeholder states so switching devices
173// never changes the card's footprint.
174const providerCardHeightClassName = "lg:h-[min(44rem,calc(100dvh-11rem))] lg:min-h-[32rem]";
175
176/**
177 * Same chrome as the provider editor (section heading, floating device tabs,
178 * tall card) for states that cannot render provider settings yet.
179 */
180function ProviderSettingsPlaceholder({
181 deviceTabs,
182 icon,
183 title,
184 description,
185 children,
186}: {
187 readonly deviceTabs?: ReactNode;
188 readonly icon: ReactNode;
189 readonly title: string;
190 readonly description: string;
191 readonly children?: ReactNode;
192}) {
193 return (
194 <SettingsSection {...searchableSetting("providers")} variant="plain">
195 {deviceTabs}
196 <div
197 className={cn(
198 providerCardClassName,
199 providerCardHeightClassName,
200 "flex overflow-x-hidden overflow-y-auto",
201 )}
202 >
203 <Empty className="min-h-88">
204 <EmptyMedia variant="icon">{icon}</EmptyMedia>
205 <EmptyHeader>
206 <EmptyTitle>{title}</EmptyTitle>
207 <EmptyDescription>{description}</EmptyDescription>
208 </EmptyHeader>
209 {children ? <EmptyContent className="max-w-xl">{children}</EmptyContent> : null}
210 </Empty>
211 </div>
212 </SettingsSection>
213 );
214}
215
216function EnvironmentUnavailablePlaceholder({
163 environment,217 environment,
164 access,218 access,
165 deviceTabs,219 deviceTabs,
...@@ -174,18 +228,33 @@ function EnvironmentUnavailableRow({...@@ -174,18 +228,33 @@ function EnvironmentUnavailableRow({
174 : access.kind === "error"228 : access.kind === "error"
175 ? "Could not connect to this device"229 ? "Could not connect to this device"
176 : "Provider settings are unavailable";230 : "Provider settings are unavailable";
231 // Keep the description to a short status; the raw failure can be a
232 // multi-paragraph CLI dump, so it goes below, clamped and expandable.
177 const description = isLoading233 const description = isLoading
178 ? access.reason === "permissions"234 ? access.reason === "permissions"
179 ? "Checking what this session is allowed to change."235 ? "Checking what this session is allowed to change."
180 : `Waiting for ${environment.label}'s configuration.`236 : `Waiting for ${environment.label}'s configuration.`
181 : connectionStatusText(environment.connection);237 : connectionStatusTitle(environment.connection);
238 const error = isLoading ? null : environment.connection.error;
182 // No spinner: this state can persist indefinitely for a wedged device, and a239 // No spinner: this state can persist indefinitely for a wedged device, and a
183 // continuously repainting animation would run the whole time.240 // continuously repainting animation would run the whole time.
184 return (241 return (
185 <SettingsSection {...searchableSetting("providers")}>242 <ProviderSettingsPlaceholder
186 {deviceTabs}243 deviceTabs={deviceTabs}
187 <SettingsRow title={title} description={description} />244 icon={
188 </SettingsSection>245 <EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(environment.serverConfig)} />
246 }
247 title={title}
248 description={description}
249 >
250 {error ? (
251 <ExpandableText
252 key={environment.environmentId}
253 text={error}
254 className="w-full text-left font-mono text-xs leading-relaxed text-muted-foreground"
255 />
256 ) : null}
257 </ProviderSettingsPlaceholder>
189 );258 );
190}259}
191260
...@@ -260,7 +329,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -260,7 +329,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
260 const machine = resolveEnvironmentMachineKind(environment.serverConfig);329 const machine = resolveEnvironmentMachineKind(environment.serverConfig);
261 const selected = environment.environmentId === effectiveEnvironmentId;330 const selected = environment.environmentId === effectiveEnvironmentId;
262 const detail = providerEnvironmentDetail(environment);331 const detail = providerEnvironmentDetail(environment);
263 const statusText = connectionStatusText(environment.connection);332 const statusText = connectionStatusTitle(environment.connection);
264 return (333 return (
265 <Tooltip key={environment.environmentId}>334 <Tooltip key={environment.environmentId}>
266 <TooltipTrigger335 <TooltipTrigger
...@@ -302,25 +371,23 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -302,25 +371,23 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
302 return (371 return (
303 <>372 <>
304 {targetEnvironmentMissing ? (373 {targetEnvironmentMissing ? (
305 <SettingsSection {...searchableSetting("providers")}>374 <ProviderSettingsPlaceholder
306 {deviceTabs}375 deviceTabs={deviceTabs}
307 <SettingsRow376 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
308 title="Device unavailable"377 title="Device unavailable"
309 description="Reconnect this device to set up its provider, or select another device."378 description="Reconnect this device to set up its provider, or select another device."
310 />379 />
311 </SettingsSection>
312 ) : null}380 ) : null}
313 {options.length === 0 && !targetEnvironmentMissing ? (381 {options.length === 0 && !targetEnvironmentMissing ? (
314 <SettingsSection {...searchableSetting("providers")}>382 <ProviderSettingsPlaceholder
315 <SettingsRow383 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
316 title={isReady ? "No connected devices" : "Loading devices"}384 title={isReady ? "No connected devices" : "Loading devices"}
317 description={385 description={
318 isReady386 isReady
319 ? "Connect an execution environment before configuring providers."387 ? "Connect an execution environment before configuring providers."
320 : "Reading connected execution environments."388 : "Reading connected execution environments."
321 }389 }
322 />390 />
323 </SettingsSection>
324 ) : null}391 ) : null}
325392
326 {selectedEnvironment ? (393 {selectedEnvironment ? (
...@@ -450,7 +517,7 @@ function AccessGatedProviderSettings({...@@ -450,7 +517,7 @@ function AccessGatedProviderSettings({
450 });517 });
451 if (access.kind !== "editable" && access.kind !== "read-only") {518 if (access.kind !== "editable" && access.kind !== "read-only") {
452 return (519 return (
453 <EnvironmentUnavailableRow520 <EnvironmentUnavailablePlaceholder
454 environment={environment}521 environment={environment}
455 access={access}522 access={access}
456 deviceTabs={deviceTabs}523 deviceTabs={deviceTabs}
...@@ -967,14 +1034,20 @@ export function EnvironmentProviderSettings({...@@ -967,14 +1034,20 @@ export function EnvironmentProviderSettings({
967 >1034 >
968 {deviceTabs}1035 {deviceTabs}
969 {readOnly ? (1036 {readOnly ? (
970 <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5">1037 <div className={cn(providerCardClassName, "overflow-hidden")}>
971 <SettingsRow1038 <SettingsRow
972 title="Limited permissions"1039 title="Limited permissions"
973 description={`This session can view ${environmentLabel}'s providers but can't change their settings.`}1040 description={`This session can view ${environmentLabel}'s providers but can't change their settings.`}
974 />1041 />
975 </div>1042 </div>
976 ) : null}1043 ) : null}
977 <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5 lg:grid lg:h-[min(44rem,calc(100dvh-11rem))] lg:min-h-[32rem] lg:grid-cols-[17rem_minmax(0,1fr)]">1044 <div
1045 className={cn(
1046 providerCardClassName,
1047 providerCardHeightClassName,
1048 "overflow-hidden lg:grid lg:grid-cols-[17rem_minmax(0,1fr)]",
1049 )}
1050 >
978 <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">1051 <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">
979 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">1052 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
980 <div className="divide-y divide-border/50">1053 <div className="divide-y divide-border/50">