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";
3838import { ScrollArea } from "../ui/scroll-area";
3939import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4040import { toastManager } from "../ui/toast";
41import { ExpandableText } from "./ExpandableText";
4142import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
4243import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
4344import { useAtomCommand } from "../../state/use-atom-command";
......@@ -161,43 +162,6 @@ function EmptyRows({ label }: { label: string }) {
161162 return <div className="px-4 py-4 text-xs text-muted-foreground sm:px-5">{label}</div>;
162163}
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
201165function DiagnosticsTable({
202166 headers,
203167 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({
607607 {instanceId}
608608 </code>
609609 ) : 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}
611615 {versionAdvisory ? (
612616 <span role="img" aria-label="Update available" className="inline-flex shrink-0">
613617 <ArrowUpCircleIcon className="size-3.5 text-muted-foreground" />
apps/web/src/components/settings/ProviderSettingsPanel.tsx+101-28
......@@ -1,5 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";
2import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
33import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
44import {
55 isAtomCommandInterrupted,
......@@ -56,6 +56,14 @@ import {
5656 type ProviderUpdateCandidate,
5757} from "../ProviderUpdateLaunchNotification.logic";
5858import { Button } from "../ui/button";
59import {
60 Empty,
61 EmptyContent,
62 EmptyDescription,
63 EmptyHeader,
64 EmptyMedia,
65 EmptyTitle,
66} from "../ui/empty";
5967import {
6068 NumberField,
6169 NumberFieldDecrement,
......@@ -67,6 +75,7 @@ import { ScrollArea } from "../ui/scroll-area";
6775import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6876import { stackedThreadToast, toastManager } from "../ui/toast";
6977import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
78import { ExpandableText } from "./ExpandableText";
7079import { ProviderInstanceCard } from "./ProviderInstanceCard";
7180import { UsageProviderSettings } from "./UsageProviderSettings";
7281import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
......@@ -159,7 +168,52 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string
159168 return environment.displayUrl ?? "Remote device";
160169}
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({
163217 environment,
164218 access,
165219 deviceTabs,
......@@ -174,18 +228,33 @@ function EnvironmentUnavailableRow({
174228 : access.kind === "error"
175229 ? "Could not connect to this device"
176230 : "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.
177233 const description = isLoading
178234 ? access.reason === "permissions"
179235 ? "Checking what this session is allowed to change."
180236 : `Waiting for ${environment.label}'s configuration.`
181 : connectionStatusText(environment.connection);
237 : connectionStatusTitle(environment.connection);
238 const error = isLoading ? null : environment.connection.error;
182239 // No spinner: this state can persist indefinitely for a wedged device, and a
183240 // continuously repainting animation would run the whole time.
184241 return (
185 <SettingsSection {...searchableSetting("providers")}>
186 {deviceTabs}
187 <SettingsRow title={title} description={description} />
188 </SettingsSection>
242 <ProviderSettingsPlaceholder
243 deviceTabs={deviceTabs}
244 icon={
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>
189258 );
190259}
191260
......@@ -260,7 +329,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
260329 const machine = resolveEnvironmentMachineKind(environment.serverConfig);
261330 const selected = environment.environmentId === effectiveEnvironmentId;
262331 const detail = providerEnvironmentDetail(environment);
263 const statusText = connectionStatusText(environment.connection);
332 const statusText = connectionStatusTitle(environment.connection);
264333 return (
265334 <Tooltip key={environment.environmentId}>
266335 <TooltipTrigger
......@@ -302,25 +371,23 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
302371 return (
303372 <>
304373 {targetEnvironmentMissing ? (
305 <SettingsSection {...searchableSetting("providers")}>
306 {deviceTabs}
307 <SettingsRow
308 title="Device unavailable"
309 description="Reconnect this device to set up its provider, or select another device."
310 />
311 </SettingsSection>
374 <ProviderSettingsPlaceholder
375 deviceTabs={deviceTabs}
376 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
377 title="Device unavailable"
378 description="Reconnect this device to set up its provider, or select another device."
379 />
312380 ) : null}
313381 {options.length === 0 && !targetEnvironmentMissing ? (
314 <SettingsSection {...searchableSetting("providers")}>
315 <SettingsRow
316 title={isReady ? "No connected devices" : "Loading devices"}
317 description={
318 isReady
319 ? "Connect an execution environment before configuring providers."
320 : "Reading connected execution environments."
321 }
322 />
323 </SettingsSection>
382 <ProviderSettingsPlaceholder
383 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
384 title={isReady ? "No connected devices" : "Loading devices"}
385 description={
386 isReady
387 ? "Connect an execution environment before configuring providers."
388 : "Reading connected execution environments."
389 }
390 />
324391 ) : null}
325392
326393 {selectedEnvironment ? (
......@@ -450,7 +517,7 @@ function AccessGatedProviderSettings({
450517 });
451518 if (access.kind !== "editable" && access.kind !== "read-only") {
452519 return (
453 <EnvironmentUnavailableRow
520 <EnvironmentUnavailablePlaceholder
454521 environment={environment}
455522 access={access}
456523 deviceTabs={deviceTabs}
......@@ -967,14 +1034,20 @@ export function EnvironmentProviderSettings({
9671034 >
9681035 {deviceTabs}
9691036 {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")}>
9711038 <SettingsRow
9721039 title="Limited permissions"
9731040 description={`This session can view ${environmentLabel}'s providers but can't change their settings.`}
9741041 />
9751042 </div>
9761043 ) : 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 >
9781051 <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">
9791052 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
9801053 <div className="divide-y divide-border/50">