From b36619b9059c6407be2054b656bf0e8f72471ed8 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 6 Apr 2026 18:51:00 +1000 Subject: [PATCH] feat: implement tabs for agent logs (#23952) This PR adds log source tabs to the workspace agent logs panel so users can quickly focus on specific log streams instead of scanning one combined feed. It also updates the shared tabs trigger behavior to explicitly use `type="button"` when rendered as a native button, preventing unintended form submission behavior. - Adds per-source tabs in `AgentRow` with an **All Logs** default view. - Shows only log sources that currently have output, with source icons and sorted labels. - Filters rendered log lines based on the active tab while preserving existing log streaming/scroll behavior. - Refines the logs container layout/styling for the new tabbed UI. - Updates `TabsTrigger` to safely default button type when not using `asChild`. https://github.com/user-attachments/assets/9b3e7a9d-72e3-4c12-aba2-2b70cdbc04c1 --- site/src/components/Tabs/Tabs.tsx | 8 +- .../modules/resources/AgentRow.stories.tsx | 70 ++++++++- site/src/modules/resources/AgentRow.tsx | 133 ++++++++++++++---- 3 files changed, 183 insertions(+), 28 deletions(-) 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 }) => ( + + )} + + + +
+
+
)}