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
+33 -17
View File
@@ -19,7 +19,7 @@ import { cn } from "#/utils/cn";
type TabsProps = ComponentProps<typeof TabsPrimitive.Root>;
export const Tabs: FC<TabsProps> = ({ ...props }) => {
return <TabsPrimitive.Root {...props} />;
return <TabsPrimitive.Root data-slot="tabs" {...props} />;
};
const tabsListVariants = cva("flex flex-wrap items-center", {
@@ -27,20 +27,20 @@ const tabsListVariants = cva("flex flex-wrap items-center", {
variant: {
insideBox: cn(
"border-solid border-x-0 border-y",
"[&_button[data-state=active]]:bg-surface-secondary",
"[&_button]:border-x [&_button]:border-y-0 [&_button]:border-solid",
"[&_button]:border-x-transparent [&_button[data-state=active]]:border-x-border",
"[&_button]:px-4",
"[&_button]:text-content-secondary",
"[&_button[data-state=active]]:text-content-primary",
"[&_[data-slot=tabs-trigger][data-state=active]]:bg-surface-secondary",
"[&_[data-slot=tabs-trigger]]:border-x [&_[data-slot=tabs-trigger]]:border-y-0 [&_[data-slot=tabs-trigger]]:border-solid",
"[&_[data-slot=tabs-trigger]]:border-x-transparent [&_[data-slot=tabs-trigger][data-state=active]]:border-x-border",
"[&_[data-slot=tabs-trigger]]:px-4",
"[&_[data-slot=tabs-trigger]]:text-content-secondary",
"[&_[data-slot=tabs-trigger][data-state=active]]:text-content-primary",
),
outsideBox: cn(
"border-solid border-0 border-b gap-6",
"[&_button]:text-content-secondary [&_button[data-state=active]]:text-content-primary",
"[&_button]:border-0 [&_button]:border-y [&_button]:border-solid",
"[&_button]:border-transparent [&_button[data-state=active]]:border-b-white",
"[&_button]:hover:text-content-primary",
"[&_button]:px-1",
"[&_[data-slot=tabs-trigger]]:text-content-secondary [&_[data-slot=tabs-trigger][data-state=active]]:text-content-primary",
"[&_[data-slot=tabs-trigger]]:border-0 [&_[data-slot=tabs-trigger]]:border-y [&_[data-slot=tabs-trigger]]:border-solid",
"[&_[data-slot=tabs-trigger]]:border-transparent [&_[data-slot=tabs-trigger][data-state=active]]:border-b-white",
"[&_[data-slot=tabs-trigger]]:hover:text-content-primary",
"[&_[data-slot=tabs-trigger]]:px-1",
),
},
},
@@ -49,16 +49,24 @@ const tabsListVariants = cva("flex flex-wrap items-center", {
},
});
type TabsListProps = ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>;
VariantProps<typeof tabsListVariants> & {
overflowKebabMenu?: boolean;
};
export const TabsList: FC<TabsListProps> = ({
className,
variant,
overflowKebabMenu = false,
...props
}) => {
return (
<TabsPrimitive.List
className={cn(tabsListVariants({ variant }), className)}
data-slot="tabs-list"
className={cn(
tabsListVariants({ variant }),
overflowKebabMenu && "flex-nowrap",
className,
)}
{...props}
/>
);
@@ -74,6 +82,7 @@ export const TabsTrigger: FC<TabsTriggerProps> = ({
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
type={type}
className={cn(
"border-none py-3 bg-transparent",
@@ -90,7 +99,7 @@ export const TabsTrigger: FC<TabsTriggerProps> = ({
type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;
export const TabsContent: FC<TabsContentProps> = ({ ...props }) => {
return <TabsPrimitive.Content {...props} />;
return <TabsPrimitive.Content data-slot="tabs-content" {...props} />;
};
// --- Router link tabs (URL-driven navigation) ---
@@ -116,6 +125,7 @@ export const LinkTabs: FC<LinkTabsProps> = ({
return (
<LinkTabsContext.Provider value={{ active }}>
<div
data-slot="link-tabs"
// Because the Tailwind preflight is not used, its necessary to set border style to solid and
// reset all border widths to 0 https://tailwindcss.com/docs/border-width#using-without-preflight
className={cn(
@@ -188,10 +198,15 @@ export const LinkTabsList: FC<LinkTabsListProps> = ({
}, [updateIndicator]);
return (
<div ref={listRef} className="relative">
<div className={cn("flex items-baseline gap-6", className)} {...props} />
<div ref={listRef} data-slot="link-tabs-list-root" className="relative">
<div
data-slot="link-tabs-list"
className={cn("flex items-baseline gap-6", className)}
{...props}
/>
<div
ref={indicatorRef}
data-slot="link-tabs-indicator"
className="absolute bottom-0 h-px bg-surface-invert-primary opacity-0 transition-all duration-300 ease-in-out"
/>
</div>
@@ -216,6 +231,7 @@ export const TabLink: FC<TabLinkProps> = ({
return (
<Link
data-slot="tab-link"
data-active={isActive}
aria-current={isActive ? "page" : undefined}
{...linkProps}
+1
View File
@@ -0,0 +1 @@
export { useTabOverflowKebabMenu } from "./useTabOverflowKebabMenu";
@@ -0,0 +1,157 @@
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
type TabLike = {
value: string;
};
type UseTabOverflowKebabMenuOptions<TTab extends TabLike> = {
tabs: readonly TTab[];
enabled: boolean;
isActive: boolean;
alwaysVisibleTabsCount?: number;
overflowTriggerWidthPx?: number;
};
type UseTabOverflowKebabMenuResult<TTab extends TabLike> = {
containerRef: RefObject<HTMLDivElement | null>;
visibleTabs: TTab[];
overflowTabs: TTab[];
getTabMeasureProps: (tabValue: string) => Record<string, string>;
};
const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value";
export const useTabOverflowKebabMenu = <TTab extends TabLike>({
tabs,
enabled,
isActive,
alwaysVisibleTabsCount = 1,
overflowTriggerWidthPx = 44,
}: UseTabOverflowKebabMenuOptions<TTab>): UseTabOverflowKebabMenuResult<TTab> => {
const containerRef = useRef<HTMLDivElement>(null);
const tabWidthByValueRef = useRef<Record<string, number>>({});
const [overflowTabValues, setOverflowTabValues] = useState<string[]>([]);
const recalculateOverflow = useCallback(() => {
if (!enabled) {
setOverflowTabValues([]);
return;
}
const container = containerRef.current;
if (!container) {
return;
}
for (const tab of tabs) {
const tabElement = container.querySelector<HTMLElement>(
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
);
if (tabElement) {
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
}
}
const alwaysVisibleTabs = tabs.slice(0, alwaysVisibleTabsCount);
const optionalTabs = tabs.slice(alwaysVisibleTabsCount);
if (optionalTabs.length === 0) {
setOverflowTabValues([]);
return;
}
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
}, 0);
const availableWidth = container.clientWidth;
let usedWidth = alwaysVisibleWidth;
const nextOverflowValues: string[] = [];
for (let i = 0; i < optionalTabs.length; i++) {
const tab = optionalTabs[i];
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
const widthNeeded =
usedWidth +
tabWidth +
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);
if (widthNeeded <= availableWidth) {
usedWidth += tabWidth;
continue;
}
nextOverflowValues.push(
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
);
break;
}
setOverflowTabValues((currentValues) => {
if (
currentValues.length === nextOverflowValues.length &&
currentValues.every(
(value, index) => value === nextOverflowValues[index],
)
) {
return currentValues;
}
return nextOverflowValues;
});
}, [alwaysVisibleTabsCount, enabled, overflowTriggerWidthPx, tabs]);
useLayoutEffect(() => {
if (!isActive) {
return;
}
recalculateOverflow();
}, [isActive, recalculateOverflow]);
useEffect(() => {
if (!isActive) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver(() => {
recalculateOverflow();
});
observer.observe(container);
return () => observer.disconnect();
}, [isActive, recalculateOverflow]);
const overflowTabValuesSet = useMemo(
() => new Set(overflowTabValues),
[overflowTabValues],
);
const visibleTabs = useMemo(
() => tabs.filter((tab) => !overflowTabValuesSet.has(tab.value)),
[tabs, overflowTabValuesSet],
);
const overflowTabs = useMemo(
() => tabs.filter((tab) => overflowTabValuesSet.has(tab.value)),
[tabs, overflowTabValuesSet],
);
const getTabMeasureProps = useCallback((tabValue: string) => {
return { [DATA_ATTR_TAB_VALUE]: tabValue };
}, []);
return {
containerRef,
visibleTabs,
overflowTabs,
getTabMeasureProps,
};
};
@@ -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>
);
};