feat: implement kebab menu overflow to <Tabs /> (#23959)

Added `data-slot` attributes to all Tabs components for better CSS
targeting and component identification. Replaced generic button
selectors with data-slot attribute selectors in tab styling variants.

Implemented `useTabOverflowKebabMenu` hook to handle tab overflow
scenarios by measuring tab widths and determining which tabs should be
hidden in a dropdown menu when container space is limited.

Enhanced the AgentRow logs section with:

- Tab overflow handling using a kebab menu (three dots) for tabs that
don't fit
- Copy logs button with visual feedback using CheckIcon animation
- Download logs functionality for selected tab content with proper
filename generation
- Improved layout with flex containers and proper spacing

Few props and components updates

* Added `overflowKebabMenu` prop to TabsList component to enable
`flex-nowrap` behavior when overflow handling is active.
* Created `<DownloadSelectedAgentLogsButton />` component to replace the
previous download functionality, now working with filtered log content
based on selected tab.


https://github.com/user-attachments/assets/af48ca39-c906-4a11-a891-0d4399eee827
This commit is contained in:
Jake Howell
2026-04-07 04:42:01 +10:00
committed by GitHub
parent 4cfbf544a0
commit 8bb48ffdda
6 changed files with 491 additions and 75 deletions
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { workspaceAgentContainersKey } from "#/api/queries/workspaces";
import type { WorkspaceAgentLogSource } from "#/api/typesGenerated";
import type * as TypesGen from "#/api/typesGenerated";
import { getPreferredProxy } from "#/contexts/ProxyContext";
import { chromatic } from "#/testHelpers/chromatic";
import * as M from "#/testHelpers/entities";
@@ -90,7 +90,7 @@ const logs = [
created_at: new Date().toISOString(),
}));
const installScriptLogSource: WorkspaceAgentLogSource = {
const installScriptLogSource: TypesGen.WorkspaceAgentLogSource = {
...M.MockWorkspaceAgentLogSource,
id: "f2ee4b8d-b09d-4f4e-a1f1-5e4adf7d53bb",
display_name: "Install Script",
@@ -120,6 +120,42 @@ const tabbedLogs = [
},
];
const overflowLogSources: TypesGen.WorkspaceAgentLogSource[] = [
M.MockWorkspaceAgentLogSource,
{
...M.MockWorkspaceAgentLogSource,
id: "58f5db69-5f78-496f-bce1-0686f5525aa1",
display_name: "code-server",
icon: "/icon/code.svg",
},
{
...M.MockWorkspaceAgentLogSource,
id: "f39d758c-bce2-4f41-8d70-58fdb1f0f729",
display_name: "Install and start AgentAPI",
icon: "/icon/claude.svg",
},
{
...M.MockWorkspaceAgentLogSource,
id: "bf7529b8-1787-4a20-b54f-eb894680e48f",
display_name: "Mux",
icon: "/icon/mux.svg",
},
{
...M.MockWorkspaceAgentLogSource,
id: "0d6ebde6-c534-4551-9f91-bfd98bfb04f4",
display_name: "Portable Desktop",
icon: "/icon/portable-desktop.svg",
},
];
const overflowLogs = overflowLogSources.map((source, index) => ({
id: 200 + index,
level: "info",
output: `${source.display_name}: line`,
source_id: source.id,
created_at: new Date().toISOString(),
}));
const meta: Meta<typeof AgentRow> = {
title: "components/AgentRow",
component: AgentRow,
@@ -402,3 +438,44 @@ export const LogsTabs: Story = {
await expect(canvas.getByText("install: pnpm install")).toBeVisible();
},
};
export const LogsTabsOverflow: Story = {
args: {
agent: {
...M.MockWorkspaceAgentReady,
logs_length: overflowLogs.length,
log_sources: overflowLogSources,
},
},
parameters: {
webSocket: [
{
event: "message",
data: JSON.stringify(overflowLogs),
},
],
},
render: (args) => (
<div className="max-w-[320px]">
<AgentRow {...args} />
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "Logs" }));
await userEvent.click(
canvas.getByRole("button", { name: "More log tabs" }),
);
const overflowItems = await page.findAllByRole("menuitemradio");
const selectedItem = overflowItems[0];
const selectedSource = selectedItem.textContent;
if (!selectedSource) {
throw new Error("Overflow menu item must have text content.");
}
await userEvent.click(selectedItem);
await waitFor(() =>
expect(canvas.getByText(`${selectedSource}: line`)).toBeVisible(),
);
},
};
+170 -56
View File
@@ -1,12 +1,17 @@
import Collapse from "@mui/material/Collapse";
import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import { PlayIcon, SquareCheckBigIcon } from "lucide-react";
import {
CopyIcon,
EllipsisIcon,
PlayIcon,
SquareCheckBigIcon,
} from "lucide-react";
import {
type FC,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -19,8 +24,16 @@ import type {
WorkspaceAgent,
WorkspaceAgentMetadata,
} from "#/api/typesGenerated";
import { CheckIcon } from "#/components/AnimatedIcons/Check";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import type { Line } from "#/components/Logs/LogLine";
import {
@@ -29,7 +42,9 @@ import {
TabsList,
TabsTrigger,
} from "#/components/Tabs/Tabs";
import { useTabOverflowKebabMenu } from "#/components/Tabs/utils";
import { useProxy } from "#/contexts/ProxyContext";
import { useClipboard } from "#/hooks/useClipboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses";
import { cn } from "#/utils/cn";
@@ -42,7 +57,7 @@ import { AgentLogs } from "./AgentLogs/AgentLogs";
import { AgentMetadata } from "./AgentMetadata";
import { AgentStatus } from "./AgentStatus";
import { AgentVersion } from "./AgentVersion";
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
import { DownloadSelectedAgentLogsButton } from "./DownloadSelectedAgentLogsButton";
import { PortForwardButton } from "./PortForwardButton";
import { AgentSSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
@@ -196,47 +211,63 @@ export const AgentRow: FC<AgentRowProps> = ({
);
const [selectedLogTab, setSelectedLogTab] = useState("all");
const sourceLogTabs = agent.log_sources
.filter((logSource) => {
// Remove the logSources that have no entries.
return agentLogs.some(
(log) =>
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
);
})
.map((logSource) => ({
// Show the icon for the log source if it has one.
// In the startup script case, we show a bespoke play icon.
startIcon: logSource.icon ? (
<ExternalImage
src={logSource.icon}
alt=""
className="size-icon-xs shrink-0"
/>
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
<PlayIcon className="size-icon-xs shrink-0" />
) : null,
title: logSource.display_name,
value: logSource.id,
}));
const startupScriptLogTab = sourceLogTabs.find(
(tab) => tab.title === STARTUP_SCRIPT_DISPLAY_NAME,
const logTabs = useMemo(() => {
const sourceLogTabs = agent.log_sources
.filter((logSource) => {
// Remove the logSources that have no entries.
return agentLogs.some(
(log) =>
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
);
})
.map((logSource) => ({
// Show the icon for the log source if it has one.
// In the startup script case, we show a bespoke play icon.
startIcon: logSource.icon ? (
<ExternalImage
src={logSource.icon}
alt=""
className="size-icon-xs shrink-0"
/>
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
<PlayIcon className="size-icon-xs shrink-0" />
) : null,
title: logSource.display_name,
value: logSource.id,
}));
const startupScriptLogTab = sourceLogTabs.find(
(tab) => tab.title === STARTUP_SCRIPT_DISPLAY_NAME,
);
const sortedSourceLogTabs = sourceLogTabs
.filter((tab) => tab !== startupScriptLogTab)
.sort((a, b) => a.title.localeCompare(b.title));
return [
{
title: "All Logs",
value: "all",
},
...(startupScriptLogTab ? [startupScriptLogTab] : []),
...sortedSourceLogTabs,
] as {
startIcon?: React.ReactNode;
title: string;
value: string;
}[];
}, [agent.log_sources, agentLogs]);
const {
containerRef: logTabsListContainerRef,
visibleTabs: visibleLogTabs,
overflowTabs: overflowLogTabs,
getTabMeasureProps,
} = useTabOverflowKebabMenu({
tabs: logTabs,
enabled: true,
isActive: showLogs,
alwaysVisibleTabsCount: 1,
});
const overflowLogTabValuesSet = new Set(
overflowLogTabs.map((tab) => tab.value),
);
const sortedSourceLogTabs = sourceLogTabs
.filter((tab) => tab !== startupScriptLogTab)
.sort((a, b) => a.title.localeCompare(b.title));
const logTabs: {
startIcon?: React.ReactNode;
title: string;
value: string;
}[] = [
{
title: "All Logs",
value: "all",
},
...(startupScriptLogTab ? [startupScriptLogTab] : []),
...sortedSourceLogTabs,
];
const selectedLogs =
selectedLogTab === "all"
? agentLogs
@@ -248,6 +279,16 @@ export const AgentRow: FC<AgentRowProps> = ({
level: log.level,
sourceId: log.source_id,
}));
const selectedLogsText = selectedLogs.map((log) => log.output).join("\n");
const hasSelectedLogs = selectedLogs.length > 0;
const { showCopiedSuccess, copyToClipboard } = useClipboard();
const selectedLogTabTitle =
logTabs.find((tab) => tab.value === selectedLogTab)?.title ?? "Logs";
const sanitizedTabTitle = selectedLogTabTitle
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
const logFilenameSuffix = sanitizedTabTitle || "logs";
return (
<div
@@ -411,17 +452,15 @@ export const AgentRow: FC<AgentRowProps> = ({
{hasStartupFeatures && (
<section className="border-0 border-t border-solid border-border">
<div className="flex flex-row gap-2 px-4 py-3">
<div className="px-4 py-2 relative">
<Button
size="sm"
variant="subtle"
onClick={() => setShowLogs((v) => !v)}
className="after:content-[''] after:absolute after:inset-0"
>
<ChevronDownIcon open={showLogs} />
Logs
<span>Logs</span>
</Button>
<Divider orientation="vertical" variant="middle" flexItem />
<DownloadAgentLogsButton agent={agent} />
</div>
<Collapse in={showLogs}>
<div className="px-4 pb-4">
@@ -431,14 +470,89 @@ export const AgentRow: FC<AgentRowProps> = ({
value={selectedLogTab}
onValueChange={setSelectedLogTab}
>
<TabsList variant="insideBox">
{logTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.startIcon}
<span>{tab.title}</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex items-stretch">
<div
ref={logTabsListContainerRef}
className="min-w-0 flex-1"
>
<TabsList variant="insideBox" overflowKebabMenu>
{visibleLogTabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
{...getTabMeasureProps(tab.value)}
>
{tab.startIcon}
<span className="whitespace-nowrap">
{tab.title}
</span>
</TabsTrigger>
))}
{overflowLogTabs.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-slot="tabs-trigger"
data-log-overflow-trigger
data-state={
overflowLogTabValuesSet.has(selectedLogTab)
? "active"
: "inactive"
}
aria-label="More log tabs"
className="border-none py-4 bg-transparent text-inherit inline-flex items-center justify-center cursor-pointer transition-colors duration-150 ease-linear"
>
<EllipsisIcon className="size-icon-sm" />
<span className="sr-only">More log tabs</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={selectedLogTab}
onValueChange={setSelectedLogTab}
>
{overflowLogTabs.map((tab) => (
<DropdownMenuRadioItem
key={tab.value}
value={tab.value}
className="gap-2"
>
{tab.startIcon}
<span className="whitespace-nowrap">
{tab.title}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</TabsList>
</div>
<div
className={cn(
"h-12.5 shrink-0 flex items-center gap-2 pl-2 pr-3",
"border-solid border-0 border-b",
)}
>
<Button
variant="subtle"
size="sm"
disabled={!hasSelectedLogs}
onClick={() => copyToClipboard(selectedLogsText)}
>
{showCopiedSuccess ? <CheckIcon /> : <CopyIcon />}
<span>Copy logs</span>
</Button>
<DownloadSelectedAgentLogsButton
agentName={agent.name}
filenameSuffix={logFilenameSuffix}
logsText={selectedLogsText}
disabled={!hasSelectedLogs}
/>
</div>
</div>
{/*
Using a singular TabsContent is necessary to avoid scrolling
issues when the selected log tab changes.
@@ -0,0 +1,51 @@
import { saveAs } from "file-saver";
import { DownloadIcon } from "lucide-react";
import { type FC, useState } from "react";
import { toast } from "sonner";
import { getErrorDetail } from "#/api/errors";
import { Button } from "#/components/Button/Button";
type DownloadSelectedAgentLogsButtonProps = {
agentName: string;
filenameSuffix: string;
logsText: string;
disabled?: boolean;
download?: (file: Blob, filename: string) => void | Promise<void>;
};
export const DownloadSelectedAgentLogsButton: FC<
DownloadSelectedAgentLogsButtonProps
> = ({
agentName,
filenameSuffix,
logsText,
disabled = false,
download = saveAs,
}) => {
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
try {
setIsDownloading(true);
const file = new Blob([logsText], { type: "text/plain" });
await download(file, `${agentName}-${filenameSuffix}.txt`);
} catch (error) {
toast.error(`Failed to download "${agentName}" logs.`, {
description: getErrorDetail(error),
});
} finally {
setIsDownloading(false);
}
};
return (
<Button
variant="subtle"
size="sm"
disabled={disabled || isDownloading}
onClick={handleDownload}
>
<DownloadIcon />
{isDownloading ? "Downloading..." : "Download logs"}
</Button>
);
};