Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container";
import { cn } from "@superset/ui/utils";
import {
type ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from "react";

/** Must match the `gap-1.5` class on the container below. */
const CHIP_GAP_PX = 6;

interface DashboardSidebarChipStripProps {
className?: string;
children: ReactNode;
}

/**
* Strip of chips (ports, agents) that stays a single row while everything
* fits, wraps into a two-row grid once it doesn't, and scrolls horizontally
* (two rows deep) beyond that.
*
* Layout is chosen by comparing the chips' natural widths against the
* container, re-measured on every render and on container resizes. Pure-CSS
* child growth (e.g. a chip revealing hover actions) deliberately doesn't
* trigger a re-measure, so hovering can't flip the layout.
*/
export function DashboardSidebarChipStrip({
className,
children,
}: DashboardSidebarChipStripProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isTwoRows, setIsTwoRows] = useState(false);

const measure = useCallback(() => {
const node = containerRef.current;
if (!node) return;
const styles = getComputedStyle(node);
const available =
node.clientWidth -
Number.parseFloat(styles.paddingLeft) -
Number.parseFloat(styles.paddingRight);
let needed = -CHIP_GAP_PX;
for (const child of Array.from(node.children)) {
needed += (child as HTMLElement).offsetWidth + CHIP_GAP_PX;
}
setIsTwoRows(needed > available);
Comment on lines +44 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Grid mode inflates chip widths, blocking revert to single row

When isTwoRows is true, the container renders as a CSS Grid with auto-cols-max grid-flow-col. Grid items stretch to fill their column area by default (justify-self: stretch), so a narrow chip sharing a column with a wider chip will report offsetWidth equal to the wider chip's width. The measure function then sums these inflated values and consistently decides needed > available, keeping the layout in two-row mode even after enough chips are removed to fit in a single row.

The fix is to add justify-items-start to the grid variant so chips only occupy their natural content width, keeping offsetWidth accurate in both layout modes. For example, isTwoRows ? "grid auto-cols-max grid-flow-col grid-rows-2 justify-items-start" : "flex".

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarChipStrip/DashboardSidebarChipStrip.tsx
Line: 44-48

Comment:
**Grid mode inflates chip widths, blocking revert to single row**

When `isTwoRows` is `true`, the container renders as a CSS Grid with `auto-cols-max grid-flow-col`. Grid items stretch to fill their column area by default (`justify-self: stretch`), so a narrow chip sharing a column with a wider chip will report `offsetWidth` equal to the wider chip's width. The `measure` function then sums these inflated values and consistently decides `needed > available`, keeping the layout in two-row mode even after enough chips are removed to fit in a single row.

The fix is to add `justify-items-start` to the grid variant so chips only occupy their natural content width, keeping `offsetWidth` accurate in both layout modes. For example, `isTwoRows ? "grid auto-cols-max grid-flow-col grid-rows-2 justify-items-start" : "flex"`.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}, []);

// No dependency array: chips mounting/unmounting re-renders this component,
// and re-measuring is cheap (setState bails when the boolean is unchanged).
useLayoutEffect(measure);

useLayoutEffect(() => {
const node = containerRef.current;
if (!node) return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [measure]);

return (
<OverflowFadeContainer
ref={containerRef}
observeChildren
className={cn(
// justify-items-start keeps chips at natural width in grid mode;
// stretched chips would inflate the offsetWidths measure() sums.
isTwoRows
? "grid auto-cols-max grid-flow-col grid-rows-2 justify-items-start"
: "flex",
"items-center gap-1.5 overflow-x-auto hide-scrollbar",
className,
)}
>
{children}
</OverflowFadeContainer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DashboardSidebarChipStrip } from "./DashboardSidebarChipStrip";
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@superset/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { cn } from "@superset/ui/utils";
import { useNavigate } from "@tanstack/react-router";
import { LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu";
import {
LuAppWindow,
LuEllipsisVertical,
LuExternalLink,
LuLoaderCircle,
LuSquareTerminal,
LuX,
} from "react-icons/lu";
import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
Expand Down Expand Up @@ -33,115 +47,162 @@ export function DashboardSidebarPortBadge({
});
};

const handleOpenInBrowser = () => {
if (!canOpenInBrowser) return;
const portUrl = `http://localhost:${port.port}`;

// Where the port opens is configurable under Settings → Links → Ports.
const url = `http://localhost:${port.port}`;
if (preferences.portOpenAction === "external") {
if (openUrl.isPending) return;
openUrl.mutate(url);
return;
}
const handleOpenExternal = () => {
if (!canOpenInBrowser || openUrl.isPending) return;
openUrl.mutate(portUrl);
};

const handleOpenInApp = (target: "new-tab" | "current-tab") => {
if (!canOpenInBrowser) return;
void navigateToV2Workspace(port.workspaceId, navigate, {
search: {
openUrl: url,
openUrlTarget:
preferences.portOpenAction === "newTab" ? "new-tab" : "current-tab",
openUrl: portUrl,
openUrlTarget: target,
openUrlRequestId: crypto.randomUUID(),
},
});
};

// Where a plain click opens the port is configurable under
// Settings → Links → Ports.
const handleOpenInBrowser = () => {
if (preferences.portOpenAction === "external") {
handleOpenExternal();
return;
}
handleOpenInApp(
preferences.portOpenAction === "newTab" ? "new-tab" : "current-tab",
);
};

// Opening the port is the primary action; remote ports can't open a local
// browser tab, so clicking those jumps to the workspace instead.
const handlePrimaryClick = canOpenInBrowser
? handleOpenInBrowser
: handleWorkspaceClick;

const handleClose = () => {
if (isPending) return;
void killPort(port);
};

return (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<div
className={cn(
"group relative mb-1 inline-flex max-w-full items-center gap-1 rounded-md",
"bg-primary/10 text-xs text-primary transition-colors hover:bg-primary/20",
isPending && "opacity-70",
)}
>
<div
className={cn(
"group flex max-w-44 shrink-0 items-center rounded",
"bg-muted/60 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
isPending && "opacity-70",
)}
>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleWorkspaceClick}
className="flex max-w-40 min-w-0 items-center gap-1 rounded-md px-2 py-1 font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={handlePrimaryClick}
disabled={isPending}
aria-busy={isPending}
className="flex min-w-0 items-center gap-1 rounded-l py-0.5 pl-1.5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{port.label ? (
<>
<span className="min-w-0 truncate">{port.label}</span>
<span className="shrink-0 font-mono font-normal text-muted-foreground">
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground/60">
{port.port}
</span>
</>
) : (
<span className="font-mono text-muted-foreground">
{port.port}
</span>
<span className="font-mono tabular-nums">{port.port}</span>
)}
</button>
{canOpenInBrowser && (
<button
type="button"
onClick={handleOpenInBrowser}
disabled={openUrl.isPending}
aria-label={`Open ${port.label || `port ${port.port}`} in browser`}
className="text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 group-hover:opacity-100"
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} showArrow={false}>
<div className="space-y-1 text-xs">
{port.label && <div className="font-medium">{port.label}</div>}
<div
className={`font-mono ${port.label ? "text-background/70" : "font-medium"}`}
>
<LuExternalLink className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
)}
localhost:{port.port}
</div>
<div className="text-background/70">{hostLabel}</div>
{(port.processName || port.pid != null) && (
<div className="text-background/70">
{port.processName}
{port.pid != null && ` (pid ${port.pid})`}
</div>
)}
<div className="text-[10px] text-background/60">
{canOpenInBrowser
? "Click to open in browser"
: "Click to open workspace"}
</div>
</div>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={handleClose}
aria-label={`Actions for ${port.label || `port ${port.port}`}`}
disabled={isPending}
aria-busy={isPending}
aria-label={`Close ${port.label || `port ${port.port}`}`}
className="pr-1 text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-70 group-hover:opacity-100"
className="flex shrink-0 items-center self-stretch rounded-r px-1 text-muted-foreground/50 opacity-0 transition-[opacity,color] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover:opacity-100 data-[state=open]:text-foreground data-[state=open]:opacity-100"
>
{isPending ? (
<LuLoaderCircle
className="size-3.5 animate-spin"
className="size-3 animate-spin"
strokeWidth={STROKE_WIDTH}
/>
) : (
<LuX className="size-3.5" strokeWidth={STROKE_WIDTH} />
<LuEllipsisVertical
className="size-3"
strokeWidth={STROKE_WIDTH}
/>
)}
</button>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} showArrow={false}>
<div className="space-y-1 text-xs">
{port.label && <div className="font-medium">{port.label}</div>}
<div
className={`font-mono ${port.label ? "text-background/70" : "font-medium"}`}
>
localhost:{port.port}
</div>
<div className="text-background/70">{hostLabel}</div>
{(port.processName || port.pid != null) && (
<div className="text-background/70">
{port.processName}
{port.pid != null && ` (pid ${port.pid})`}
</div>
)}
{!canOpenInBrowser && (
<div className="text-[10px] text-background/60">
Browser open unavailable from this device
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{canOpenInBrowser && (
<>
<DropdownMenuItem
onSelect={handleOpenExternal}
disabled={openUrl.isPending}
>
<LuExternalLink />
Open in External Browser
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleOpenInApp(
preferences.portOpenAction === "pane"
? "current-tab"
: "new-tab",
)
}
>
<LuAppWindow />
Open in Superset Browser
</DropdownMenuItem>
</>
)}
<div className="text-[10px] text-background/60">
Click to open workspace
</div>
</div>
</TooltipContent>
</Tooltip>
<DropdownMenuItem onSelect={handleWorkspaceClick}>
<LuSquareTerminal />
Go to Workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={handleClose}
disabled={isPending}
>
<LuX />
Close Port
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { cn } from "@superset/ui/utils";
import { useNavigate } from "@tanstack/react-router";
import { LuLoaderCircle, LuX } from "react-icons/lu";
import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants";
import { DashboardSidebarChipStrip } from "../../../DashboardSidebarChipStrip";
import { useDashboardSidebarPortKill } from "../../hooks/useDashboardSidebarPortKill";
import type { DashboardSidebarPortGroup as DashboardSidebarPortGroupType } from "../../hooks/useDashboardSidebarPortsData";
import { DashboardSidebarPortBadge } from "../DashboardSidebarPortBadge";
Expand Down Expand Up @@ -69,17 +69,14 @@ export function DashboardSidebarPortGroup({
</TooltipContent>
</Tooltip>
</div>
<OverflowFadeContainer
observeChildren
className="grid auto-cols-max grid-flow-col grid-rows-2 gap-1 overflow-x-auto px-3 pb-1 hide-scrollbar"
>
<DashboardSidebarChipStrip className="px-3 pb-1">
{group.ports.map((port) => (
<DashboardSidebarPortBadge
key={`${port.hostId}:${port.terminalId}:${port.port}`}
port={port}
/>
))}
</OverflowFadeContainer>
</DashboardSidebarChipStrip>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from "@superset/ui/utils";
import { DashboardSidebarChipStrip } from "../../../../components/DashboardSidebarChipStrip";
import { DashboardSidebarWorkspaceAgentBadge } from "./components/DashboardSidebarWorkspaceAgentBadge";
import { useDashboardSidebarWorkspaceRunningAgents } from "./hooks/useDashboardSidebarWorkspaceRunningAgents";

Expand All @@ -18,11 +19,9 @@ export function DashboardSidebarWorkspaceAgentsRow({
}

return (
<div
className={cn(
"flex flex-wrap gap-1 pr-2 pb-1",
isInSection ? "pl-14" : "pl-12",
)}
<DashboardSidebarChipStrip
// pl lines the chips up with the summary label text above.
className={cn("pr-2", isInSection ? "pl-11" : "pl-9")}
>
{agents.map((agent) => (
<DashboardSidebarWorkspaceAgentBadge
Expand All @@ -31,6 +30,6 @@ export function DashboardSidebarWorkspaceAgentsRow({
agent={agent}
/>
))}
</div>
</DashboardSidebarChipStrip>
);
}
Loading
Loading