fix: unify connection failure screens and rebuild stale local plugins

This commit is contained in:
LukeGus committed 2026-10-06 15:21:44 -05:00
1 parent a72aa5b57b
commit 49fc241e06
9 files changed
+262 -29

No files matched your search

+9 -1
View File
@@ -3118,7 +3118,15 @@ export function AppShell({
? paneId === focusedPaneId
: activeInline;
return createPortal(
<SurfaceScope kind="tab" className="h-full w-full">
<SurfaceScope
kind="tab"
className="h-full w-full"
onClose={
tab.id === "dashboard"
? undefined
: () => closeTab(tab.id)
}
>
{renderTabContent(tab, {
shell: shellCallbacks,
settings: settingsTabProps,
@@ -11,6 +11,7 @@ interface ConnectionLogPanelProps {
isConnecting: boolean;
isConnected: boolean;
hasConnectionError: boolean;
/** Kept for older callers. The log always sits at the bottom. */
position?: "top" | "bottom";
className?: string;
}
@@ -37,7 +38,6 @@ export function ConnectionLogPanel({
isConnecting,
isConnected,
hasConnectionError,
position = "bottom",
className,
}: ConnectionLogPanelProps) {
const { t } = useTranslation();
@@ -109,15 +109,13 @@ export function ConnectionLogPanel({
<div
className={cn(
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
position === "bottom"
? "border-t border-border"
: "border-b border-border",
"border-t border-border",
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
"transition-[height] duration-200",
className,
)}
>
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-2 pr-1.5">
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-2 pr-1.5">
<button
type="button"
onClick={handleToggle}
@@ -2,8 +2,10 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/button.tsx";
import { RefreshCw } from "lucide-react";
import { RefreshCw, X } from "lucide-react";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useSurfaceClose } from "@/components/surface/surface-scope.tsx";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
export interface ConnectionUnavailable {
@@ -19,9 +21,7 @@ export interface ConnectionScreenProps {
message?: string;
/** Second line under the headline, usually user@host:port. */
detail?: string;
/** Headline once it has failed and is not retrying on its own. */
errorMessage?: string;
/** The reason it failed, shown under the headline. */
/** The reason it failed. Falls back to the last error in the log. */
errorDetail?: string | null;
/** Shown instead of a failure when the feature cannot run here. */
unavailable?: ConnectionUnavailable | null;
@@ -30,8 +30,10 @@ export interface ConnectionScreenProps {
maxAttempts?: number;
nextRetryInMs?: number | null;
onManualRetry?: () => void;
retryLabel?: string;
/** Headline when it was up and dropped. */
disconnectedMessage?: string;
/** Overrides closing the tab this sits in. False hides the button. */
onClose?: (() => void) | false;
extraActions?: React.ReactNode;
logPosition?: "top" | "bottom";
emptyState?: React.ReactNode;
@@ -43,7 +45,6 @@ export function ConnectionScreen({
status,
message,
detail,
errorMessage,
errorDetail,
unavailable,
backgroundColor,
@@ -51,14 +52,17 @@ export function ConnectionScreen({
maxAttempts = 0,
nextRetryInMs = null,
onManualRetry,
retryLabel,
disconnectedMessage,
onClose,
extraActions,
logPosition = "bottom",
emptyState,
className,
}: ConnectionScreenProps) {
const { t } = useTranslation();
const surfaceClose = useSurfaceClose();
const logs = useOptionalConnectionLog()?.logs;
const close = onClose === false ? null : (onClose ?? surfaceClose);
if (status === "connected" && !emptyState && !unavailable) {
return null;
@@ -68,16 +72,22 @@ export function ConnectionScreen({
const failed = status === "error" || status === "disconnected";
const retrying =
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
const showRetryButton = (failed || !!unavailable) && !!onManualRetry;
const stopped = failed || !!unavailable;
const showRetryButton = stopped && !!onManualRetry;
const showClose = stopped && !!close;
const reason =
errorDetail ||
[...(logs ?? [])].reverse().find((entry) => entry.type === "error")
?.message ||
null;
const showLog = !emptyState && (status !== "connected" || !!unavailable);
let headline = message;
if (unavailable) headline = unavailable.title;
else if (status === "disconnected")
headline = disconnectedMessage || t("connection.disconnected");
else if (retrying) headline = t("connection.failedRetrying");
else if (status === "error")
headline = errorMessage || t("connection.failed");
else if (status === "disconnected" && disconnectedMessage)
headline = disconnectedMessage;
else if (failed) headline = t("connection.failed");
const showCountdown = attempt > 0 && (connecting || retrying);
@@ -123,9 +133,9 @@ export function ConnectionScreen({
{unavailable.hint}
</p>
)}
{failed && !unavailable && errorDetail && (
{failed && !unavailable && reason && (
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
{errorDetail}
{reason}
</p>
)}
{showCountdown && (
@@ -145,7 +155,8 @@ export function ConnectionScreen({
</div>
{(showRetryButton ||
((failed || unavailable) && extraActions) ||
showClose ||
(stopped && extraActions) ||
unavailable?.action) && (
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
{showRetryButton && (
@@ -158,11 +169,22 @@ export function ConnectionScreen({
<RefreshCw className="size-3.5" />
{retrying
? t("connection.retryNow")
: retryLabel || t("connection.reconnect")}
: t("connection.reconnect")}
</Button>
)}
{unavailable?.action}
{(failed || unavailable) && extraActions}
{stopped && extraActions}
{showClose && (
<Button
variant="outline"
size="sm"
onClick={close}
className="gap-2 font-semibold"
>
<X className="size-3.5" />
{t("connection.close")}
</Button>
)}
</div>
)}
</div>
@@ -173,7 +195,7 @@ export function ConnectionScreen({
<ConnectionLogPanel
isConnecting={connecting}
isConnected={false}
hasConnectionError={failed || !!unavailable}
hasConnectionError={stopped}
position={logPosition}
/>
)}
+18 -1
View File
@@ -50,6 +50,7 @@ interface ScopeValue {
closeView: (id: string) => void;
topView: string | null;
confirm: (options: ConfirmOptions) => Promise<boolean>;
close: (() => void) | null;
}
const ScopeContext = createContext<ScopeValue | null>(null);
@@ -65,10 +66,13 @@ const keyOwners: number[] = [];
export function SurfaceScope({
kind = "panel",
onEditingChange,
onClose,
className,
children,
}: {
kind?: SurfaceKind;
/** Closes whatever this surface is, like the tab it fills. */
onClose?: () => void;
/** Gets the widest open inline view, so a sidebar can widen for it. */
onEditingChange?: (editing: EditingWidth) => void;
className?: string;
@@ -80,6 +84,13 @@ export function SurfaceScope({
const nextConfirm = useRef(0);
const editingRef = useRef(onEditingChange);
editingRef.current = onEditingChange;
const closeRef = useRef(onClose);
closeRef.current = onClose;
const hasClose = !!onClose;
const close = useMemo(
() => (hasClose ? () => closeRef.current?.() : null),
[hasClose],
);
const openView = useCallback((id: string, width: EditingWidth) => {
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
@@ -126,8 +137,9 @@ export function SurfaceScope({
closeView,
topView: views.length ? views[views.length - 1].id : null,
confirm,
close,
}),
[kind, overlay, openView, closeView, views, confirm],
[kind, overlay, openView, closeView, views, confirm, close],
);
const current = confirms[0];
@@ -165,6 +177,11 @@ export function useSurfaceKind(): SurfaceKind | null {
return useContext(ScopeContext)?.kind ?? null;
}
/** Closes the surface this code renders in, or null when it can't be closed. */
export function useSurfaceClose(): (() => void) | null {
return useContext(ScopeContext)?.close ?? null;
}
/**
* Asks a yes or no question over the surface that called it. Enter confirms
* and Esc cancels, before a terminal can see the key.
+1 -1
View File
@@ -2,8 +2,8 @@
"connection": {
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
"disconnected": "Unable to connect",
"reconnect": "Reconnect",
"close": "Close",
"failed": "Could not connect",
"failedRetrying": "Connection failed, trying again",
"retryNow": "Retry now"
@@ -6,7 +6,12 @@ vi.mock("react-i18next", () => ({
}));
import { ConnectionScreen } from "../../../components/connection/ConnectionScreen";
import { ConnectionLogProvider } from "../../../ssh/connection-log/ConnectionLogContext";
import {
ConnectionLogProvider,
useConnectionLog,
} from "../../../ssh/connection-log/ConnectionLogContext";
import { SurfaceScope } from "../../../components/surface/surface-scope";
import { useEffect } from "react";
afterEach(cleanup);
@@ -107,6 +112,75 @@ describe("ConnectionScreen", () => {
expect(screen.getByText("connection.reconnect")).toBeTruthy();
});
it("uses the same headline when retries run out", () => {
render(<ConnectionScreen status="disconnected" message="common.loading" />);
expect(screen.getByText("connection.failed")).toBeTruthy();
});
it("closes the tab it sits in once it stops", () => {
const close = vi.fn();
render(
<SurfaceScope kind="tab" onClose={close}>
<ConnectionScreen status="error" onManualRetry={() => {}} />
</SurfaceScope>,
);
fireEvent.click(screen.getByText("connection.close"));
expect(close).toHaveBeenCalledTimes(1);
});
it("prefers its own close and can hide it", () => {
const own = vi.fn();
const { rerender } = render(
<SurfaceScope kind="tab" onClose={() => {}}>
<ConnectionScreen status="error" onClose={own} />
</SurfaceScope>,
);
fireEvent.click(screen.getByText("connection.close"));
expect(own).toHaveBeenCalledTimes(1);
rerender(
<SurfaceScope kind="tab" onClose={() => {}}>
<ConnectionScreen status="error" onClose={false} />
</SurfaceScope>,
);
expect(screen.queryByText("connection.close")).toBeNull();
});
it("shows no close while connecting or outside a tab", () => {
render(
<SurfaceScope kind="tab" onClose={() => {}}>
<ConnectionScreen status="connecting" />
</SurfaceScope>,
);
expect(screen.queryByText("connection.close")).toBeNull();
cleanup();
render(<ConnectionScreen status="error" />);
expect(screen.queryByText("connection.close")).toBeNull();
});
it("falls back to the last logged error for the reason", () => {
function Seed() {
const { addLog } = useConnectionLog();
useEffect(() => {
addLog({ type: "error", stage: "connection", message: "old" });
addLog({ type: "info", stage: "connection", message: "trying" });
addLog({
type: "error",
stage: "connection",
message: "Docker not found",
});
}, [addLog]);
return null;
}
render(
<ConnectionLogProvider>
<Seed />
<ConnectionScreen status="error" />
</ConnectionLogProvider>,
);
expect(screen.getAllByText("Docker not found").length).toBeGreaterThan(1);
});
it("renders nothing once connected", () => {
const { container } = render(<ConnectionScreen status="connected" />);
expect(container.innerHTML).toBe("");