diff --git a/backend/version.py b/backend/version.py
index c5d3265..4de2d68 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
-APP_VERSION = "0.42.0"
+APP_VERSION = "0.42.1"
diff --git a/frontend/package.json b/frontend/package.json
index 44675dc..d0f8f1b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
- "version": "0.42.0",
+ "version": "0.42.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/layout/VersionBadge.tsx b/frontend/src/components/layout/VersionBadge.tsx
index 4c56c70..9e64bc5 100644
--- a/frontend/src/components/layout/VersionBadge.tsx
+++ b/frontend/src/components/layout/VersionBadge.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpCircle } from "lucide-react";
import { toast } from "sonner";
@@ -80,8 +81,10 @@ export function VersionBadge() {
);
}
-/** Full-screen wait state while the helper recreates the containers:
- * polls /api/health until a different version answers, then reloads. */
+/** Full-screen wait state while the helper recreates the containers: polls
+ * /api/health until a different version answers, then reloads. Portalled for
+ * the same reason as ConfirmDialog — the backdrop-blurred top bar this is
+ * rendered from would otherwise be the containing block for `fixed`. */
function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
const [failed, setFailed] = useState(false);
const started = useRef(Date.now());
@@ -109,7 +112,7 @@ function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
return () => clearInterval(timer);
}, [fromVersion]);
- return (
+ return createPortal(
+ );
+}
+
+/**
+ * Stacked progress bar for the status banner, where vertical space is not at
+ * a premium. Update streams real per-layer pull progress; the other actions
+ * have nothing to measure and show a sliding bar instead.
*/
export function ActionProgressBar({
status,
@@ -26,18 +112,8 @@ export function ActionProgressBar({
status: StackActionStatus;
onDismiss?: (id: string) => void;
}) {
- const { phase, progress } = status;
- const done = phase !== "running";
- const pct = phase === "success" ? 100 : Math.min(progress?.pct ?? 0, 100);
- // No stream (or nothing measurable yet) — slide instead of showing a lie.
- const indeterminate = !done && (progress?.indeterminate ?? true);
-
- const label = done
- ? phase === "success"
- ? `${status.label} — done`
- : `${status.label} failed`
- : (progress?.label ?? `${status.label}…`);
- const detail = phase === "error" ? (status.message ?? "") : (progress?.detail ?? "");
+ const { phase } = status;
+ const { done, pct, indeterminate, label, detail } = barState(status);
return (
diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx
index 8d61712..9d7372e 100644
--- a/frontend/src/components/stacks/StacksTable.tsx
+++ b/frontend/src/components/stacks/StacksTable.tsx
@@ -10,7 +10,7 @@ import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types";
import type { StackActionStatus } from "@/hooks/useStackActions";
-import { ActionProgressBar } from "./ActionStatusBanner";
+import { ActionProgressInline } from "./ActionStatusBanner";
/** Stack list rendered as a table with live CPU/memory usage meters and inline
* start/stop/restart, shared by the Dashboard and the Stacks page. */
@@ -161,13 +161,20 @@ function StackRow({
return (
-
-
- {stack.name}
- {stack.status}
-
- {stack.running_count}/{stack.service_count} svc
-
+ {/* Name block stays fixed-width; a running action's bar fills the gap
+ to the CPU column, so the row never changes height. */}
+
{running && stats ? (
diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx
index cbd8958..474f6e7 100644
--- a/frontend/src/components/ui/ConfirmDialog.tsx
+++ b/frontend/src/components/ui/ConfirmDialog.tsx
@@ -1,6 +1,14 @@
import type { ReactNode } from "react";
+import { createPortal } from "react-dom";
import { Button } from "@/components/ui";
+/**
+ * Rendered through a portal on `document.body`: an ancestor with a transform,
+ * filter or backdrop-filter becomes the containing block for `position: fixed`
+ * descendants, which would pin the dialog to that ancestor's box instead of the
+ * viewport. The top bar is `backdrop-blur`, so a dialog opened from it (the
+ * self-update prompt) was centred in the 60px header and cut off at the top.
+ */
export function ConfirmDialog({
title,
message,
@@ -20,13 +28,16 @@ export function ConfirmDialog({
onCancel: () => void;
children?: ReactNode;
}) {
- return (
+ return createPortal(
!busy && onCancel()}
>
+ {/* The dialog scrolls itself rather than the backdrop: `items-center`
+ plus `overflow-y-auto` on one element makes the overflowing top
+ unreachable. */}