mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user