feat(ux): URL chip overflow popover + status pill deep-link

3.4: Bumped MAX_VISIBLE chips to 3 (was 2). When >3 endpoints exist,
extras collapse into a "+N" pill that opens a click-to-open popover
listing the rest as real clickable links. Replaces the previous
title-attribute tooltip which was undiscoverable on touch and
keyboard. Closes on outside click and Escape.

3.5: Status pill "Logs" affordance now deep-links to the Coolify
project page (one click from build logs) instead of Coolify's root.
Also surfaced on `deploying` and `down` states, not just
`build_failed` — when something's happening Coolify-side, users
want to peek regardless of the exact state.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-01 12:42:48 -07:00
parent e415268115
commit 871f465079
2 changed files with 158 additions and 20 deletions

View File

@@ -8,15 +8,18 @@
* - Prev. chips → every running dev-server preview
*
* When there are more than MAX_VISIBLE total links, extras collapse
* into a "+N more" pill (shows the full list in a tooltip via title).
* into a "+N" pill that opens a popover with the full list as
* clickable links (was a `title=` tooltip before — popover is
* discoverable on touch and keyboard, tooltip wasn't).
*
* Polls anatomy at the same cadence as the status pill.
*/
import { ExternalLink, Globe, Zap } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAnatomy } from "./use-anatomy";
const MAX_VISIBLE = 2;
const MAX_VISIBLE = 3;
interface Props {
projectId: string;
@@ -24,6 +27,29 @@ interface Props {
export function ProjectHeaderUrls({ projectId }: Props) {
const { anatomy } = useAnatomy(projectId, { pollMs: 4000 });
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowRef = useRef<HTMLDivElement>(null);
// Close popover on outside click / Escape — both expected by users
// who don't realize it's modal-ish.
useEffect(() => {
if (!overflowOpen) return;
function onDoc(e: MouseEvent) {
if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
setOverflowOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOverflowOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [overflowOpen]);
if (!anatomy) return null;
const liveLinks = anatomy.hosting.live
@@ -50,7 +76,7 @@ export function ProjectHeaderUrls({ projectId }: Props) {
if (allLinks.length === 0) return null;
const visible = allLinks.slice(0, MAX_VISIBLE);
const hidden = allLinks.slice(MAX_VISIBLE);
const hidden = allLinks.slice(MAX_VISIBLE);
return (
<div style={wrap}>
@@ -71,12 +97,42 @@ export function ProjectHeaderUrls({ projectId }: Props) {
</a>
))}
{hidden.length > 0 && (
<span
style={overflowPill}
title={hidden.map(l => `${l.label}: ${l.url}`).join("\n")}
>
+{hidden.length} more
</span>
<div ref={overflowRef} style={{ position: "relative" }}>
<button
type="button"
onClick={() => setOverflowOpen((v) => !v)}
aria-expanded={overflowOpen}
aria-haspopup="true"
style={overflowPill}
>
+{hidden.length}
</button>
{overflowOpen && (
<div role="menu" style={popoverStyle}>
<div style={popoverHeader}>{hidden.length} more endpoint{hidden.length === 1 ? "" : "s"}</div>
{hidden.map((l) => (
<a
key={l.key}
href={l.url}
target="_blank"
rel="noreferrer"
style={popoverItem}
onClick={() => setOverflowOpen(false)}
role="menuitem"
>
{l.kind === "live"
? <Globe size={12} style={{ flexShrink: 0, color: "#1a1a1a" }} />
: <Zap size={12} style={{ flexShrink: 0, color: "#3d5afe" }} />}
<div style={popoverItemText}>
<div style={popoverItemLabel}>{l.label}</div>
<div style={popoverItemHost}>{l.host}</div>
</div>
<ExternalLink size={11} style={{ flexShrink: 0, opacity: 0.5 }} />
</a>
))}
</div>
)}
</div>
)}
</div>
);
@@ -125,7 +181,65 @@ const chipLabel: React.CSSProperties = {
const overflowPill: React.CSSProperties = {
...chipBase,
borderColor: "#e8e4dc",
color: "#a09a90",
color: "#6b665e",
background: "#f8f5f0",
cursor: "default",
cursor: "pointer",
font: "inherit",
fontSize: "0.72rem",
fontWeight: 500,
};
const popoverStyle: React.CSSProperties = {
position: "absolute",
top: "calc(100% + 6px)",
right: 0,
minWidth: 240,
maxWidth: 360,
padding: 4,
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 6,
boxShadow: "0 6px 20px rgba(0,0,0,0.08)",
zIndex: 50,
display: "flex",
flexDirection: "column",
gap: 2,
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
};
const popoverHeader: React.CSSProperties = {
padding: "6px 10px 4px",
fontSize: "0.65rem",
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "#a09a90",
fontWeight: 600,
};
const popoverItem: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 4,
textDecoration: "none",
color: "#1a1a1a",
fontSize: "0.78rem",
cursor: "pointer",
};
const popoverItemText: React.CSSProperties = {
flex: 1,
minWidth: 0,
overflow: "hidden",
};
const popoverItemLabel: React.CSSProperties = {
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
const popoverItemHost: React.CSSProperties = {
fontSize: "0.7rem",
color: "#a09a90",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};

View File

@@ -59,11 +59,35 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
}
const visual = VISUALS[state.kind];
// For build failures, surface a "View logs" link next to the pill so
// the user can immediately see why the deploy broke.
const coolifyDeployUrl = anatomy?.hosting.live[0]?.uuid
? `${typeof window !== "undefined" ? "" : ""}` // resolved client-side
: null;
// Deep-link target for the "Logs" affordance. Coolify v4 redirects
// `/project/<uuid>` to that project's first environment, putting
// the user one click from any application's deployment logs. We
// don't store the environment UUID in anatomy (yet), so this is
// the closest we can get without an extra Coolify call. When
// anatomy exposes `lastBuildDeploymentUuid` we can deep-link
// straight to `/applications/.../deployment/<dep-uuid>`.
const coolifyBase = process.env.NEXT_PUBLIC_COOLIFY_URL ?? "";
const coolifyProjectUuid = anatomy?.project?.coolifyProjectUuid;
const coolifyDeepLink =
coolifyBase && coolifyProjectUuid
? `${coolifyBase.replace(/\/$/, "")}/project/${coolifyProjectUuid}`
: coolifyBase || null;
// Show the "Logs" affordance whenever there's something interesting
// happening Coolify-side: a build is in flight, the last build
// failed, or apps are down. Hide on `live` and `empty` to avoid
// visual noise when nothing's wrong.
const showLogsLink =
coolifyDeepLink &&
(state.kind === "build_failed" ||
state.kind === "deploying" ||
state.kind === "down");
const logsLinkColor =
state.kind === "build_failed" || state.kind === "down"
? "#c5392b"
: "#3d5afe";
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
@@ -74,15 +98,15 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
title={state.reason}
spinning={state.kind === "deploying"}
/>
{state.kind === "build_failed" && anatomy?.hosting.live[0] && (
{showLogsLink && (
<a
href={`${process.env.NEXT_PUBLIC_COOLIFY_URL ?? ""}`}
href={coolifyDeepLink}
target="_blank"
rel="noreferrer"
title="Open Coolify to view build logs"
title={`Open Coolify build logs in a new tab`}
style={{
display: "inline-flex", alignItems: "center", gap: 3,
fontSize: "0.68rem", color: "#c5392b",
fontSize: "0.68rem", color: logsLinkColor,
textDecoration: "none", opacity: 0.8,
}}
>