diff --git a/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts b/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts index 42040b3e8f..518606aa64 100644 --- a/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts +++ b/site/src/components/Tabs/utils/useTabOverflowKebabMenu.ts @@ -1,12 +1,4 @@ -import { - type RefObject, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; type TabLike = { value: string; @@ -38,97 +30,85 @@ export const useTabOverflowKebabMenu = ({ }: UseTabOverflowKebabMenuOptions): UseTabOverflowKebabMenuResult => { const containerRef = useRef(null); const tabWidthByValueRef = useRef>({}); + const tabsRef = useRef(tabs); 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]); + tabsRef.current = tabs; useEffect(() => { - if (!isActive) { + if (!enabled || !isActive) { + setOverflowTabValues([]); return; } + const container = containerRef.current; if (!container) { return; } - const observer = new ResizeObserver(() => { - recalculateOverflow(); - }); + + const recalculateOverflow = () => { + const currentTabs = tabsRef.current; + for (const tab of currentTabs) { + const tabElement = container.querySelector( + `[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`, + ); + if (tabElement) { + tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth; + } + } + + const alwaysVisibleTabs = currentTabs.slice(0, alwaysVisibleTabsCount); + const optionalTabs = currentTabs.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; + }); + }; + + recalculateOverflow(); + const observer = new ResizeObserver(recalculateOverflow); observer.observe(container); return () => observer.disconnect(); - }, [isActive, recalculateOverflow]); + }, [alwaysVisibleTabsCount, enabled, isActive, overflowTriggerWidthPx]); const overflowTabValuesSet = useMemo( () => new Set(overflowTabValues), @@ -144,9 +124,9 @@ export const useTabOverflowKebabMenu = ({ [tabs, overflowTabValuesSet], ); - const getTabMeasureProps = useCallback((tabValue: string) => { + const getTabMeasureProps = (tabValue: string) => { return { [DATA_ATTR_TAB_VALUE]: tabValue }; - }, []); + }; return { containerRef, diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index 39697f330e..460959ddf4 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 * as TypesGen from "#/api/typesGenerated"; +import type { WorkspaceAgentLogSource } from "#/api/typesGenerated"; import { getPreferredProxy } from "#/contexts/ProxyContext"; import { chromatic } from "#/testHelpers/chromatic"; import * as M from "#/testHelpers/entities"; @@ -92,7 +92,7 @@ const logs = [ created_at: fixedLogTimestamp, })); -const installScriptLogSource: TypesGen.WorkspaceAgentLogSource = { +const installScriptLogSource: WorkspaceAgentLogSource = { ...M.MockWorkspaceAgentLogSource, id: "f2ee4b8d-b09d-4f4e-a1f1-5e4adf7d53bb", display_name: "Install Script", @@ -122,42 +122,6 @@ 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: fixedLogTimestamp, -})); - const meta: Meta = { title: "components/AgentRow", component: AgentRow, @@ -440,44 +404,3 @@ 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 ff49ce5049..5a8fda4434 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -8,10 +8,9 @@ import { } from "lucide-react"; import { type FC, - useCallback, + type ReactNode, useEffect, useLayoutEffect, - useMemo, useRef, useState, } from "react"; @@ -162,7 +161,7 @@ export const AgentRow: FC = ({ // This is a bit of a hack on the react-window API to get the scroll position. // If we're scrolled to the bottom, we want to keep the list scrolled to the bottom. // This makes it feel similar to a terminal that auto-scrolls downwards! - const handleLogScroll = useCallback((props: ListOnScrollProps) => { + const handleLogScroll = (props: ListOnScrollProps) => { if ( props.scrollOffset === 0 || props.scrollUpdateWasRequested || @@ -179,7 +178,7 @@ export const AgentRow: FC = ({ logListDivRef.current.scrollHeight - (props.scrollOffset + parent.clientHeight); setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT); - }, []); + }; const devcontainers = useAgentContainers(agent); @@ -211,49 +210,47 @@ export const AgentRow: FC = ({ ); const [selectedLogTab, setSelectedLogTab] = useState("all"); - 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 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)); + const logTabs: { + startIcon?: ReactNode; + title: string; + value: string; + }[] = [ + { + title: "All Logs", + value: "all", + }, + ...(startupScriptLogTab ? [startupScriptLogTab] : []), + ...sortedSourceLogTabs, + ]; const { containerRef: logTabsListContainerRef, visibleTabs: visibleLogTabs, @@ -279,16 +276,29 @@ export const AgentRow: FC = ({ level: log.level, sourceId: log.source_id, })); + const allLogsText = agentLogs.map((log) => log.output).join("\n"); const selectedLogsText = selectedLogs.map((log) => log.output).join("\n"); const hasSelectedLogs = selectedLogs.length > 0; + const hasAnyLogs = agentLogs.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"; + const downloadableLogSets = logTabs + .filter((tab) => tab.value !== "all") + .map((tab) => { + const logsText = agentLogs + .filter((log) => log.source_id === tab.value) + .map((log) => log.output) + .join("\n"); + const filenameSuffix = tab.title + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, ""); + return { + label: tab.title, + filenameSuffix: filenameSuffix || tab.value, + logsText, + startIcon: tab.startIcon, + }; + }); return (
= ({
diff --git a/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx b/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx index 3187c94737..df75d9f091 100644 --- a/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx +++ b/site/src/modules/resources/DownloadSelectedAgentLogsButton.tsx @@ -1,14 +1,27 @@ import { saveAs } from "file-saver"; -import { DownloadIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PackageIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; import { toast } from "sonner"; import { getErrorDetail } from "#/api/errors"; import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; + +type DownloadableLogSet = { + label: string; + filenameSuffix: string; + logsText: string; + startIcon?: ReactNode; +}; type DownloadSelectedAgentLogsButtonProps = { agentName: string; - filenameSuffix: string; - logsText: string; + logSets: readonly DownloadableLogSet[]; + allLogsText: string; disabled?: boolean; download?: (file: Blob, filename: string) => void | Promise; }; @@ -17,13 +30,13 @@ export const DownloadSelectedAgentLogsButton: FC< DownloadSelectedAgentLogsButtonProps > = ({ agentName, - filenameSuffix, - logsText, + logSets, + allLogsText, disabled = false, download = saveAs, }) => { const [isDownloading, setIsDownloading] = useState(false); - const handleDownload = async () => { + const downloadLogs = async (logsText: string, filenameSuffix: string) => { try { setIsDownloading(true); const file = new Blob([logsText], { type: "text/plain" }); @@ -37,15 +50,40 @@ export const DownloadSelectedAgentLogsButton: FC< } }; + const hasAllLogs = allLogsText.length > 0; + return ( - + + + + + + { + downloadLogs(allLogsText, "all-logs"); + }} + > + + Download all logs + + {logSets.map((logSet) => ( + { + downloadLogs(logSet.logsText, logSet.filenameSuffix); + }} + > + {logSet.startIcon} + Download {logSet.label} + + ))} + + ); };