From 8bb48ffddacb8c8cf3635de71706f347dc2cbcb2 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 7 Apr 2026 04:42:01 +1000 Subject: [PATCH] feat: implement kebab menu overflow to `` (#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 `` 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 --- site/src/components/Tabs/Tabs.tsx | 50 ++-- site/src/components/Tabs/utils/index.ts | 1 + .../Tabs/utils/useTabOverflowKebabMenu.ts | 157 ++++++++++++ .../modules/resources/AgentRow.stories.tsx | 81 ++++++- site/src/modules/resources/AgentRow.tsx | 226 +++++++++++++----- .../DownloadSelectedAgentLogsButton.tsx | 51 ++++ 6 files changed, 491 insertions(+), 75 deletions(-) create mode 100644 site/src/components/Tabs/utils/index.ts create mode 100644 site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts create mode 100644 site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index 5ce4934c58..1cb5eb686a 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -19,7 +19,7 @@ import { cn } from "#/utils/cn"; type TabsProps = ComponentProps; export const Tabs: FC = ({ ...props }) => { - return ; + return ; }; 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 & - VariantProps; + VariantProps & { + overflowKebabMenu?: boolean; + }; export const TabsList: FC = ({ className, variant, + overflowKebabMenu = false, ...props }) => { return ( ); @@ -74,6 +82,7 @@ export const TabsTrigger: FC = ({ return ( = ({ type TabsContentProps = ComponentProps; export const TabsContent: FC = ({ ...props }) => { - return ; + return ; }; // --- Router link tabs (URL-driven navigation) --- @@ -116,6 +125,7 @@ export const LinkTabs: FC = ({ return (
= ({ }, [updateIndicator]); return ( -
-
+
+
@@ -216,6 +231,7 @@ export const TabLink: FC = ({ return ( = { + tabs: readonly TTab[]; + enabled: boolean; + isActive: boolean; + alwaysVisibleTabsCount?: number; + overflowTriggerWidthPx?: number; +}; + +type UseTabOverflowKebabMenuResult = { + containerRef: RefObject; + visibleTabs: TTab[]; + overflowTabs: TTab[]; + getTabMeasureProps: (tabValue: string) => Record; +}; + +const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value"; + +export const useTabOverflowKebabMenu = ({ + tabs, + enabled, + isActive, + alwaysVisibleTabsCount = 1, + overflowTriggerWidthPx = 44, +}: UseTabOverflowKebabMenuOptions): UseTabOverflowKebabMenuResult => { + const containerRef = useRef(null); + const tabWidthByValueRef = useRef>({}); + const [overflowTabValues, setOverflowTabValues] = useState([]); + + const recalculateOverflow = useCallback(() => { + if (!enabled) { + setOverflowTabValues([]); + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + for (const tab of tabs) { + const tabElement = container.querySelector( + `[${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, + }; +}; diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index 7715fb78d8..995e99723c 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -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 = { 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) => ( +
+ +
+ ), + 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(), + ); + }, +}; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 0c8e513a91..ff49ce5049 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -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 = ({ ); 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 ? ( - - ) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? ( - - ) : 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 ? ( + + ) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? ( + + ) : 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 = ({ 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 (
= ({ {hasStartupFeatures && (
-
+
- -
@@ -431,14 +470,89 @@ export const AgentRow: FC = ({ value={selectedLogTab} onValueChange={setSelectedLogTab} > - - {logTabs.map((tab) => ( - - {tab.startIcon} - {tab.title} - - ))} - +
+
+ + {visibleLogTabs.map((tab) => ( + + {tab.startIcon} + + {tab.title} + + + ))} + {overflowLogTabs.length > 0 && ( + + + + + + + {overflowLogTabs.map((tab) => ( + + {tab.startIcon} + + {tab.title} + + + ))} + + + + )} + +
+
+ + +
+
{/* Using a singular TabsContent is necessary to avoid scrolling issues when the selected log tab changes. diff --git a/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx b/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx new file mode 100644 index 0000000000..3187c94737 --- /dev/null +++ b/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx @@ -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; +}; + +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 ( + + ); +};