diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx index 1b4891c135..5ce4934c58 100644 --- a/site/src/components/Tabs/Tabs.tsx +++ b/site/src/components/Tabs/Tabs.tsx @@ -66,9 +66,15 @@ export const TabsList: FC = ({ type TabsTriggerProps = ComponentProps; -export const TabsTrigger: FC = ({ ...props }) => { +export const TabsTrigger: FC = ({ + type: triggerType = "button", + ...props +}) => { + const type = props.asChild ? undefined : triggerType; + return ( = { title: "components/AgentRow", component: AgentRow, @@ -334,3 +365,40 @@ export const FoundDevcontainer: Story = { webSocket: [], }, }; + +export const LogsTabs: Story = { + args: { + agent: { + ...M.MockWorkspaceAgentReady, + logs_length: tabbedLogs.length, + log_sources: [M.MockWorkspaceAgentLogSource, installScriptLogSource], + }, + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify(tabbedLogs), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Logs" })); + + const installTab = await canvas.findByRole("tab", { + name: "Install Script", + }); + await userEvent.click(installTab); + + await waitFor(() => + expect(installTab).toHaveAttribute("data-state", "active"), + ); + await waitFor(() => + expect( + canvas.queryByText("startup: preparing workspace"), + ).not.toBeInTheDocument(), + ); + await expect(canvas.getByText("install: pnpm install")).toBeVisible(); + }, +}; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 193fa9e593..0c8e513a91 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -1,7 +1,7 @@ import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; -import { SquareCheckBigIcon } from "lucide-react"; +import { PlayIcon, SquareCheckBigIcon } from "lucide-react"; import { type FC, useCallback, @@ -21,6 +21,14 @@ import type { } from "#/api/typesGenerated"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; +import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; +import type { Line } from "#/components/Logs/LogLine"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "#/components/Tabs/Tabs"; import { useProxy } from "#/contexts/ProxyContext"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses"; @@ -89,6 +97,8 @@ const getAgentBorderClass = ( ); }; +const STARTUP_SCRIPT_DISPLAY_NAME = "Startup Script"; + export const AgentRow: FC = ({ agent, subAgents, @@ -185,11 +195,65 @@ export const AgentRow: FC = ({ Boolean(hasDevcontainerErrors || shouldShowWildcardWarning), ); + 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 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 + : agentLogs.filter((log) => log.source_id === selectedLogTab); + const selectedLogLines: readonly Line[] = selectedLogs.map((log) => ({ + id: log.id, + output: log.output, + time: log.created_at, + level: log.level, + sourceId: log.source_id, + })); + return (
@@ -347,30 +411,6 @@ export const AgentRow: FC = ({ {hasStartupFeatures && (
- - - {({ width }) => ( - ({ - id: l.id, - level: l.level, - output: l.output, - sourceId: l.source_id, - time: l.created_at, - }))} - sources={agent.log_sources} - /> - )} - - -
+ +
+
+ + + {logTabs.map((tab) => ( + + {tab.startIcon} + {tab.title} + + ))} + + {/* + Using a singular TabsContent is necessary to avoid scrolling + issues when the selected log tab changes. + */} + + + {({ width }) => ( + + )} + + + +
+
+
)}