From 249b71b96add6f35f716dbcfd442a45151474361 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 22 Apr 2026 15:34:32 +0200 Subject: [PATCH] feat(site): add Debug panel components and settings (#23920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add the Debug panel UI components: run list, run cards, step cards with transcript and tool-call rendering, attempt accordions, and shared primitives. Wire the panel into the AgentChatPage sidebar and add the per-chat debug logging toggle in the behavior settings page. This is PR 8/9 in the chat debug logging stack. ### Screenshots Settings Page CleanShot 2026-04-21 at 21 19 36@2x Conditional Debug tab CleanShot 2026-04-21 at 21 19 58@2x Last request's tools and schema CleanShot 2026-04-21 at 21 20 03@2x "Raw" JSON request bodies CleanShot 2026-04-21 at 21 20 35@2x ### Changes - **DebugPanel** (`site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx`): top-level panel component owning data fetching and subscription lifecycle. - **DebugRunCard**: compact single-row header with capitalized provider name, status badge, compact duration (`1.3s`), and token summary (`3→5 tok`). Expandable to show child step cards. - **DebugStepCard**: step inspector with normalized transcript rendering — system prompts, assistant text with 160-char clamping + independent "see more/less" toggle, tool calls with fully-expanded JSON payloads in `CopyableCodeBlock`. - **DebugAttemptAccordion**: nested accordion for HTTP-level attempt details showing request/response headers and bodies. - **Shared primitives**: `CopyableCodeBlock`, `MessageRow`, `ToolPayloadDisclosure`, `StatusBadge`. - **Sidebar wiring** (`AgentChatPageView.tsx`): adds the Debug tab to the right panel when debug logging is enabled. - **Behavior settings**: deployment-wide and per-user debug logging toggles on the settings page. ### Stack overview 1. Database schema & SDK types 2. Types, context, and model normalization 3. Recorder, transport, and redaction 4. Service and summary aggregation 5. Chat lifecycle wiring 6. HTTP handlers and API docs 7. Frontend API layer and panel utilities 8. **→ Debug panel components and settings** (this PR) 9. Storybook stories --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_ --- site/src/api/queries/chats.test.ts | 33 ++ site/src/api/queries/chats.ts | 79 +++- site/src/pages/AgentsPage/AgentChatPage.tsx | 5 + .../AgentsPage/AgentChatPageView.stories.tsx | 1 + .../pages/AgentsPage/AgentChatPageView.tsx | 121 +++-- .../AgentsPage/AgentSettingsBehaviorPage.tsx | 25 + .../AgentSettingsBehaviorPageView.tsx | 38 +- .../AgentsPage/AgentsPageView.stories.tsx | 23 +- .../components/DebugLoggingSettings.tsx | 136 ++++++ .../DebugPanel/DebugAttemptAccordion.tsx | 186 ++++++++ .../RightPanel/DebugPanel/DebugPanel.tsx | 102 ++++ .../DebugPanel/DebugPanelPrimitives.tsx | 192 ++++++++ .../RightPanel/DebugPanel/DebugRunCard.tsx | 178 +++++++ .../RightPanel/DebugPanel/DebugRunList.tsx | 30 ++ .../RightPanel/DebugPanel/DebugStepCard.tsx | 435 ++++++++++++++++++ .../DebugPanel/DebugStepCardTooling.tsx | 169 +++++++ .../DebugPanel/debugPanelUtils.test.ts | 170 ++++++- .../RightPanel/DebugPanel/debugPanelUtils.ts | 105 +++-- .../Sidebar/SidebarTabView.stories.tsx | 2 +- .../components/Sidebar/SidebarTabView.tsx | 29 +- .../Sidebar/getEffectiveTabId.test.ts | 38 ++ .../components/Sidebar/getEffectiveTabId.ts | 32 ++ 22 files changed, 2003 insertions(+), 126 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugAttemptAccordion.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCard.tsx create mode 100644 site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCardTooling.tsx create mode 100644 site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts create mode 100644 site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index d2a5e495ee..ba7022714b 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2,6 +2,10 @@ import { QueryClient } from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; +import { + ERROR_STATUSES, + SUCCESS_STATUSES, +} from "#/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils"; import { buildOptimisticEditedMessage } from "./chatMessageEdits"; import { addChildToParentInCache, @@ -27,6 +31,7 @@ import { regenerateChatTitle, removeChildFromParentInCache, reorderPinnedChat, + TERMINAL_RUN_STATUSES, unarchiveChat, unpinChat, updateChatPlanMode, @@ -1893,3 +1898,31 @@ describe("removeChildFromParentInCache", () => { expect(after).toBe(before); }); }); + +describe("TERMINAL_RUN_STATUSES", () => { + // `TERMINAL_RUN_STATUSES` lives in the api/queries layer to avoid a + // dependency on the page tree, but it must stay in sync with the + // debug panel's display classification. This test pins that invariant + // so adding a new success/error status in the panel is immediately + // caught if the polling set is forgotten. + it("contains every SUCCESS and ERROR status from the debug panel", () => { + for (const status of SUCCESS_STATUSES) { + expect(TERMINAL_RUN_STATUSES.has(status)).toBe(true); + } + for (const status of ERROR_STATUSES) { + expect(TERMINAL_RUN_STATUSES.has(status)).toBe(true); + } + }); + + // The reverse direction catches a TERMINAL status that stops polling + // but renders a neutral badge. Adding e.g. "timed_out" to TERMINAL + // without SUCCESS or ERROR would paint a finished run gray, so the + // status classification must stay bidirectional. + it("covers every TERMINAL status with SUCCESS or ERROR", () => { + for (const status of TERMINAL_RUN_STATUSES) { + const classified = + SUCCESS_STATUSES.has(status) || ERROR_STATUSES.has(status); + expect(classified).toBe(true); + } + }); +}); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e7dcc548eb..0f206f14a7 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1,7 +1,8 @@ -import type { - InfiniteData, - QueryClient, - UseInfiniteQueryOptions, +import { + type InfiniteData, + type QueryClient, + queryOptions, + type UseInfiniteQueryOptions, } from "react-query"; import { API, @@ -860,7 +861,75 @@ export const updateChatTitle = (queryClient: QueryClient) => ({ }); export const chatDebugRunsKey = (chatId: string) => - ["chats", chatId, "debug-runs"] as const; + [...chatKey(chatId), "debug-runs"] as const; + +const chatDebugRunKey = (chatId: string, runId: string) => + [...chatDebugRunsKey(chatId), runId] as const; + +// Foreground poll cadence when the Debug tab is open. The error cadence +// is slower so a transiently unreachable backend is not hammered, but +// the panel still recovers automatically once the request succeeds. +const DEBUG_RUN_POLL_MS = 5_000; +const DEBUG_RUN_ERROR_POLL_MS = 30_000; + +// Terminal debug-run statuses that stop the detail query from polling. +// Kept here (rather than imported from the debug panel page) so the +// api/queries layer has no dependency on the page tree. Must stay in +// sync with the success/error classification in the debug panel's +// status-badge logic: any status that renders a non-active badge +// (green/destructive) must end polling, otherwise a successful run +// with status "ok" or "succeeded" would be polled forever. A test in +// chats.test.ts pins this set to the debug panel's SUCCESS/ERROR +// display sets so drift is caught at CI time. +export const TERMINAL_RUN_STATUSES = new Set([ + // Success-like. + "completed", + "success", + "succeeded", + "ok", + // Error-like. + "failed", + "error", + "errored", + "interrupted", + "cancelled", + "canceled", +]); + +export const chatDebugRuns = (chatId: string) => + queryOptions({ + queryKey: chatDebugRunsKey(chatId), + queryFn: () => API.experimental.getChatDebugRuns(chatId), + refetchInterval: ({ state }) => { + // Keep polling on error with backoff so a transient fetch + // failure does not freeze the panel until a manual remount. + if (state.status === "error") { + return DEBUG_RUN_ERROR_POLL_MS; + } + // Consistent foreground cadence while the Debug tab is open. + // A slower terminal-state interval would delay discovery of + // newly-started runs until the user switches tabs. + return DEBUG_RUN_POLL_MS; + }, + refetchIntervalInBackground: false, + }); + +export const chatDebugRun = (chatId: string, runId: string) => + queryOptions({ + queryKey: chatDebugRunKey(chatId, runId), + queryFn: () => API.experimental.getChatDebugRun(chatId, runId), + refetchInterval: ({ state }) => { + if (state.status === "error") { + return DEBUG_RUN_ERROR_POLL_MS; + } + const status = state.data?.status; + if (status && TERMINAL_RUN_STATUSES.has(status.toLowerCase())) { + return false; + } + return DEBUG_RUN_POLL_MS; + }, + refetchIntervalInBackground: false, + }); const invalidateChatDebugRuns = (queryClient: QueryClient, chatId: string) => { return queryClient.invalidateQueries({ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 1b72fcf220..5bd26cc0ec 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -32,6 +32,7 @@ import { updateChatPlanMode, updateChatWorkspace, updateInfiniteChatsCache, + userChatDebugLogging, userCompactionThresholds, } from "#/api/queries/chats"; import { deploymentSSHConfig } from "#/api/queries/deployment"; @@ -619,6 +620,7 @@ const AgentChatPage: FC = () => { const chatModelConfigsQuery = useQuery(chatModelConfigs()); const userThresholdsQuery = useQuery(userCompactionThresholds()); const desktopEnabledQuery = useQuery(chatDesktopEnabled()); + const userDebugLoggingQuery = useQuery(userChatDebugLogging()); const mcpServersQuery = useQuery(mcpServerConfigs()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const workspaceOptions = filterWorkspaceOptionsByOrganization( @@ -626,6 +628,8 @@ const AgentChatPage: FC = () => { chatQuery.data?.organization_id, ); const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false; + const debugLoggingEnabled = + userDebugLoggingQuery.data?.debug_logging_enabled ?? false; // MCP server selection state. const mcpServers = mcpServersQuery.data ?? []; @@ -1400,6 +1404,7 @@ const AgentChatPage: FC = () => { onSetShowSidebarPanel={handleSetShowSidebarPanel} prNumber={prNumber} diffStatusData={chatQuery.data?.diff_status} + debugLoggingEnabled={debugLoggingEnabled} gitWatcher={gitWatcher} sshCommand={sshCommand} handleCommit={handleCommit} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 77c09e7db3..b290e7eb7c 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -138,6 +138,7 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { diffStatusData: undefined as ComponentProps< typeof AgentChatPageView >["diffStatusData"], + debugLoggingEnabled: false, gitWatcher: buildGitWatcher(), sshCommand: undefined as string | undefined, handleCommit: fn(), diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 6bb4be2dc3..b087187b3e 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -30,7 +30,9 @@ import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; import { ChatScrollContainer } from "./components/ChatScrollContainer"; import { ChatTopBar } from "./components/ChatTopBar"; import { GitPanel } from "./components/GitPanel/GitPanel"; +import { DebugPanel } from "./components/RightPanel/DebugPanel/DebugPanel"; import { RightPanel } from "./components/RightPanel/RightPanel"; +import { getEffectiveTabId } from "./components/Sidebar/getEffectiveTabId"; import { SidebarTabView } from "./components/Sidebar/SidebarTabView"; import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon"; import { TerminalPanel } from "./components/TerminalPanel"; @@ -122,6 +124,7 @@ interface AgentChatPageViewProps { // Sidebar content data. prNumber: number | undefined; diffStatusData: ChatDiffStatus | undefined; + debugLoggingEnabled: boolean; gitWatcher: { repositories: ReadonlyMap; refresh: () => boolean; @@ -205,6 +208,7 @@ export const AgentChatPageView: FC = ({ onSetShowSidebarPanel, prNumber, diffStatusData, + debugLoggingEnabled, gitWatcher, sshCommand, handleCommit, @@ -275,6 +279,8 @@ export const AgentChatPageView: FC = ({ onOpenDesktop: desktopChatId ? handleOpenDesktop : undefined, }; + const shouldShowSidebar = showSidebarPanel; + // Compute local diff stats from git watcher unified diffs. // Prefer the git repository root over the agent's expanded directory @@ -305,14 +311,79 @@ export const AgentChatPageView: FC = ({ }; })(); + // Desktop is only available when the workspace + agent are ready; + // `SidebarTabView` gates the desktop tab/panel on the same condition, + // so resolve tab selection against the same availability to avoid + // picking "desktop" when no desktop panel is rendered. + const availableDesktopChatId = + workspace && workspaceAgent ? desktopChatId : undefined; + // Single source of truth for available tabs and their order. The list + // of tab IDs used by `getEffectiveTabId` is derived from this so a + // new tab can never be added to one without the other going out of + // sync. + const sidebarTabConfigs = [ + { id: "git", label: "Git" }, + ...(workspace && workspaceAgent + ? [{ id: "terminal", label: "Terminal" }] + : []), + ...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []), + ]; + const sidebarTabIds = sidebarTabConfigs.map((tab) => tab.id); + const effectiveSidebarTabId = getEffectiveTabId( + sidebarTabIds, + sidebarTabId, + availableDesktopChatId, + ); + const renderTabContent = (tabId: string): ReactNode => { + switch (tabId) { + case "git": + return ( + + ); + case "terminal": + return workspace && workspaceAgent ? ( + + ) : null; + case "debug": + return ( + + ); + default: + return null; + } + }; + const sidebarTabs = sidebarTabConfigs.map((tab) => ({ + id: tab.id, + label: tab.label, + content: renderTabContent(tab.id), + })); + const titleElement = ( {chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")} ); - const shouldShowSidebar = showSidebarPanel; - return ( = ({ onToggleSidebarCollapsed={onToggleSidebarCollapsed} > - ), - }, - ...(workspace && workspaceAgent - ? [ - { - id: "terminal", - label: "Terminal", - content: ( - - ), - }, - ] - : []), - ]} + tabs={sidebarTabs} onClose={() => onSetShowSidebarPanel(false)} isExpanded={visualExpanded} onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} chatTitle={chatTitle} - desktopChatId={ - workspace && workspaceAgent ? desktopChatId : undefined - } + desktopChatId={availableDesktopChatId} /> diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPage.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPage.tsx index 49ca5609aa..9546881324 100644 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsBehaviorPage.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { + chatDebugLogging, chatDesktopEnabled, chatModelConfigs, chatPlanModeInstructions, @@ -9,13 +10,16 @@ import { chatUserCustomPrompt, chatWorkspaceTTL, deleteUserCompactionThreshold, + updateChatDebugLogging, updateChatDesktopEnabled, updateChatPlanModeInstructions, updateChatRetentionDays, updateChatSystemPrompt, updateChatWorkspaceTTL, updateUserChatCustomPrompt, + updateUserChatDebugLogging, updateUserCompactionThreshold, + userChatDebugLogging, userCompactionThresholds, } from "#/api/queries/chats"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -50,6 +54,19 @@ const AgentSettingsBehaviorPage: FC = () => { updateChatDesktopEnabled(queryClient), ); + const debugLoggingQuery = useQuery({ + ...chatDebugLogging(), + enabled: permissions.editDeploymentConfig, + }); + const saveDebugLoggingMutation = useMutation( + updateChatDebugLogging(queryClient), + ); + + const userDebugLoggingQuery = useQuery(userChatDebugLogging()); + const saveUserDebugLoggingMutation = useMutation( + updateUserChatDebugLogging(queryClient), + ); + const workspaceTTLQuery = useQuery(chatWorkspaceTTL()); const saveWorkspaceTTLMutation = useMutation( updateChatWorkspaceTTL(queryClient), @@ -89,6 +106,8 @@ const AgentSettingsBehaviorPage: FC = () => { planModeInstructionsData={planModeInstructionsQuery.data} userPromptData={userPromptQuery.data} desktopEnabledData={desktopEnabledQuery.data} + debugLoggingData={debugLoggingQuery.data} + userDebugLoggingData={userDebugLoggingQuery.data} workspaceTTLData={workspaceTTLQuery.data} isWorkspaceTTLLoading={workspaceTTLQuery.isLoading} isWorkspaceTTLLoadError={workspaceTTLQuery.isError} @@ -112,6 +131,12 @@ const AgentSettingsBehaviorPage: FC = () => { onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate} isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending} isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError} + onSaveDebugLogging={saveDebugLoggingMutation.mutate} + isSavingDebugLogging={saveDebugLoggingMutation.isPending} + isSaveDebugLoggingError={saveDebugLoggingMutation.isError} + onSaveUserDebugLogging={saveUserDebugLoggingMutation.mutate} + isSavingUserDebugLogging={saveUserDebugLoggingMutation.isPending} + isSaveUserDebugLoggingError={saveUserDebugLoggingMutation.isError} onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate} isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending} isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError} diff --git a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx index 26eb9b118e..07f8d7e343 100644 --- a/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings"; +import { DebugLoggingSettings } from "./components/DebugLoggingSettings"; import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings"; import { PlanModeInstructionsSettings } from "./components/PlanModeInstructionsSettings"; import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings"; @@ -25,6 +26,8 @@ interface AgentSettingsBehaviorPageViewProps { | undefined; userPromptData: TypesGen.UserChatCustomPrompt | undefined; desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined; + debugLoggingData: TypesGen.ChatDebugLoggingAdminSettings | undefined; + userDebugLoggingData: TypesGen.UserChatDebugLoggingSettings | undefined; workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined; isWorkspaceTTLLoading: boolean; isWorkspaceTTLLoadError: boolean; @@ -74,6 +77,20 @@ interface AgentSettingsBehaviorPageViewProps { isSavingDesktopEnabled: boolean; isSaveDesktopEnabledError: boolean; + onSaveDebugLogging: ( + req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest, + options?: MutationCallbacks, + ) => void; + isSavingDebugLogging: boolean; + isSaveDebugLoggingError: boolean; + + onSaveUserDebugLogging: ( + req: TypesGen.UpdateUserChatDebugLoggingRequest, + options?: MutationCallbacks, + ) => void; + isSavingUserDebugLogging: boolean; + isSaveUserDebugLoggingError: boolean; + onSaveWorkspaceTTL: ( req: TypesGen.UpdateChatWorkspaceTTLRequest, options?: MutationCallbacks, @@ -97,6 +114,8 @@ export const AgentSettingsBehaviorPageView: FC< planModeInstructionsData, userPromptData, desktopEnabledData, + debugLoggingData, + userDebugLoggingData, workspaceTTLData, isWorkspaceTTLLoading, isWorkspaceTTLLoadError, @@ -123,6 +142,12 @@ export const AgentSettingsBehaviorPageView: FC< onSaveDesktopEnabled, isSavingDesktopEnabled, isSaveDesktopEnabledError, + onSaveDebugLogging, + isSavingDebugLogging, + isSaveDebugLoggingError, + onSaveUserDebugLogging, + isSavingUserDebugLogging, + isSaveUserDebugLoggingError, onSaveWorkspaceTTL, isSavingWorkspaceTTL, isSaveWorkspaceTTLError, @@ -137,7 +162,7 @@ export const AgentSettingsBehaviorPageView: FC<
+ { }} userPromptData={{ custom_prompt: "" }} desktopEnabledData={{ enable_desktop: false }} + debugLoggingData={{ + allow_users: false, + forced_by_deployment: false, + }} + userDebugLoggingData={{ + debug_logging_enabled: false, + forced_by_deployment: false, + user_toggle_allowed: false, + }} workspaceTTLData={{ workspace_ttl_ms: 0 }} isWorkspaceTTLLoading={false} isWorkspaceTTLLoadError={false} @@ -189,6 +198,12 @@ const BehaviorRouteElement = () => { onSaveDesktopEnabled={fn()} isSavingDesktopEnabled={false} isSaveDesktopEnabledError={false} + onSaveDebugLogging={fn()} + isSavingDebugLogging={false} + isSaveDebugLoggingError={false} + onSaveUserDebugLogging={fn()} + isSavingUserDebugLogging={false} + isSaveUserDebugLoggingError={false} onSaveWorkspaceTTL={fn()} isSavingWorkspaceTTL={false} isSaveWorkspaceTTLError={false} @@ -670,7 +685,7 @@ export const OpensSettingsForAdmins: Story = { await waitFor(() => { expect( screen.getByText( - "Custom instructions that shape how the agent responds in your conversations.", + "Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.", ), ).toBeInTheDocument(); }); @@ -690,7 +705,7 @@ export const OpensSettingsForNonAdmins: Story = { await waitFor(() => { expect( screen.getByText( - "Custom instructions that shape how the agent responds in your conversations.", + "Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.", ), ).toBeInTheDocument(); }); @@ -708,7 +723,7 @@ export const SettingsViewResets: Story = { await waitFor(() => { expect( screen.getByText( - "Custom instructions that shape how the agent responds in your conversations.", + "Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.", ), ).toBeInTheDocument(); }); @@ -732,7 +747,7 @@ export const SettingsViewResets: Story = { await waitFor(() => { expect( screen.getByText( - "Custom instructions that shape how the agent responds in your conversations.", + "Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic.", ), ).toBeInTheDocument(); }); diff --git a/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx b/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx new file mode 100644 index 0000000000..9c7d85bfdb --- /dev/null +++ b/site/src/pages/AgentsPage/components/DebugLoggingSettings.tsx @@ -0,0 +1,136 @@ +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Switch } from "#/components/Switch/Switch"; +import { AdminBadge } from "./AdminBadge"; + +interface MutationCallbacks { + onSuccess?: () => void; + onError?: () => void; +} + +interface DebugLoggingSettingsProps { + canManageAdminSetting: boolean; + adminSettings: TypesGen.ChatDebugLoggingAdminSettings | undefined; + userSettings: TypesGen.UserChatDebugLoggingSettings | undefined; + onSaveAdminSetting: ( + req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest, + options?: MutationCallbacks, + ) => void; + isSavingAdminSetting: boolean; + isSaveAdminSettingError: boolean; + onSaveUserSetting: ( + req: TypesGen.UpdateUserChatDebugLoggingRequest, + options?: MutationCallbacks, + ) => void; + isSavingUserSetting: boolean; + isSaveUserSettingError: boolean; +} + +export const DebugLoggingSettings: FC = ({ + canManageAdminSetting, + adminSettings, + userSettings, + onSaveAdminSetting, + isSavingAdminSetting, + isSaveAdminSettingError, + onSaveUserSetting, + isSavingUserSetting, + isSaveUserSettingError, +}) => { + const forcedByDeployment = + userSettings?.forced_by_deployment ?? + adminSettings?.forced_by_deployment ?? + false; + const adminAllowsUsers = adminSettings?.allow_users ?? false; + const userDebugLoggingEnabled = userSettings?.debug_logging_enabled ?? false; + const userToggleAllowed = userSettings?.user_toggle_allowed ?? false; + + return ( +
+ {canManageAdminSetting && ( +
+
+

+ Let users record chat debug logs +

+ +
+
+
+ {forcedByDeployment ? ( +

+ Debug logging is already enabled deployment-wide, so this + per-user setting has no effect right now. +

+ ) : ( +

+ Lets users turn on debug logging for their own chats from + their Behavior settings. When on, Coder saves each chat turn + along with the raw API requests and responses sent to the + model provider. +

+ )} +
+ + onSaveAdminSetting({ allow_users: checked }) + } + aria-label="Allow users to enable chat debug logging" + disabled={forcedByDeployment || isSavingAdminSetting} + /> +
+ {isSaveAdminSettingError && ( +

+ Failed to save the admin debug logging setting. +

+ )} +
+ )} + +
+
+

+ Record debug logs for my chats +

+
+
+
+ {forcedByDeployment ? ( +

+ An administrator has enabled debug logging for every chat in + this deployment, so this toggle is locked on. +

+ ) : userToggleAllowed ? ( +

+ Save a detailed trace of your chats: each turn plus the raw API + requests and responses sent to the model provider. Useful for + troubleshooting unexpected model behavior. +

+ ) : ( +

+ An administrator hasn't allowed users to record chat debug logs + yet. +

+ )} +
+ + onSaveUserSetting({ debug_logging_enabled: checked }) + } + aria-label="Enable personal chat debug logging" + disabled={ + forcedByDeployment || !userToggleAllowed || isSavingUserSetting + } + /> +
+ {isSaveUserSettingError && ( +

+ Failed to save your chat debug logging preference. +

+ )} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugAttemptAccordion.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugAttemptAccordion.tsx new file mode 100644 index 0000000000..c1c6ecf739 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugAttemptAccordion.tsx @@ -0,0 +1,186 @@ +import { ChevronDownIcon } from "lucide-react"; +import type { FC } from "react"; +import { Badge } from "#/components/Badge/Badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { cn } from "#/utils/cn"; +import { DATE_FORMAT, formatDateTime, humanDuration } from "#/utils/time"; +import { + DebugCodeBlock, + DebugDataSection, + EmptyHelper, +} from "./DebugPanelPrimitives"; +import { + computeDurationMs, + getStatusBadgeVariant, + type NormalizedAttempt, + safeJsonStringify, +} from "./debugPanelUtils"; + +interface DebugAttemptAccordionProps { + attempts: NormalizedAttempt[]; + rawFallback?: string; +} + +interface JsonBlockProps { + value: unknown; + fallbackCopy: string; +} + +const JsonBlock: FC = ({ value, fallbackCopy }) => { + if ( + value === null || + value === undefined || + (typeof value === "string" && value.length === 0) || + (typeof value === "object" && Object.keys(value as object).length === 0) + ) { + return ; + } + + if (typeof value === "string") { + return ; + } + + return ; +}; + +const getAttemptTimingLabel = (attempt: NormalizedAttempt): string => { + const startedLabel = attempt.started_at + ? formatDateTime(attempt.started_at, DATE_FORMAT.TIME_24H) + : "-"; + const finishedLabel = attempt.finished_at + ? formatDateTime(attempt.finished_at, DATE_FORMAT.TIME_24H) + : "in progress"; + + const durationMs = + attempt.duration_ms ?? + (attempt.started_at + ? computeDurationMs(attempt.started_at, attempt.finished_at) + : null); + const durationLabel = + durationMs !== null ? humanDuration(durationMs) : "Duration unavailable"; + + return `${startedLabel} → ${finishedLabel} • ${durationLabel}`; +}; + +export const DebugAttemptAccordion: FC = ({ + attempts, + rawFallback, +}) => { + if (rawFallback) { + // No DebugDataSection wrapper here. The parent already + // wraps us in . + return ( +
+

+ Unable to parse raw attempts. Showing the original payload exactly as + it was captured. +

+ +
+ ); + } + + if (attempts.length === 0) { + return ( +

No attempts captured.

+ ); + } + + return ( +
+ {attempts.map((attempt, index) => ( + +
+ + + + +
+ + + + + + + + + +
+
+
+
+ ))} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx new file mode 100644 index 0000000000..a62c721454 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanel.tsx @@ -0,0 +1,102 @@ +import type { FC, ReactNode } from "react"; +import { useQuery } from "react-query"; +import { getErrorMessage } from "#/api/errors"; +import { chatDebugRuns } from "#/api/queries/chats"; +import { Alert } from "#/components/Alert/Alert"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { DebugRunList } from "./DebugRunList"; + +interface DebugPanelProps { + chatId: string; + isVisible?: boolean; +} + +export const DebugPanel: FC = ({ + chatId, + isVisible = false, +}) => { + const runsQuery = useQuery({ + ...chatDebugRuns(chatId), + enabled: isVisible, + }); + + const sortedRuns = (runsQuery.data ?? []).toSorted((left, right) => { + const rightTime = Date.parse(right.started_at || right.updated_at) || 0; + const leftTime = Date.parse(left.started_at || left.updated_at) || 0; + return rightTime - leftTime; + }); + + const hasRunsData = runsQuery.data !== undefined; + const refreshWarning = + runsQuery.isError && hasRunsData ? ( +
+ +

+ {getErrorMessage( + runsQuery.error, + "Unable to refresh debug runs. Showing cached data.", + )} +

+
+
+ ) : null; + + let content: ReactNode; + if (runsQuery.isError && !hasRunsData) { + content = ( +
+ +

+ {getErrorMessage( + runsQuery.error, + "Unable to load debug panel data.", + )} +

+
+
+ ); + } else if (runsQuery.isLoading) { + content = ( +
+ + Loading debug runs... +
+ ); + } else if (sortedRuns.length === 0) { + content = ( + <> + {refreshWarning} +
+

+ No debug runs recorded yet +

+

+ Debug logging captures LLM request/response data for each chat turn, + title generation, and compaction operation. +

+

Send a message in this chat to start capturing debug data.

+
+ + ); + } else { + content = ( + <> + {refreshWarning} + + + ); + } + + return ( + +
+ {content} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx new file mode 100644 index 0000000000..1ea40266e3 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugPanelPrimitives.tsx @@ -0,0 +1,192 @@ +import type { FC, ReactNode } from "react"; +import { Badge } from "#/components/Badge/Badge"; +import { CopyButton } from "#/components/CopyButton/CopyButton"; +import { cn } from "#/utils/cn"; +import { getRoleBadgeVariant, safeJsonStringify } from "./debugPanelUtils"; + +interface DebugDataSectionProps { + title: string; + description?: ReactNode; + children: ReactNode; + className?: string; +} + +export const DebugDataSection: FC = ({ + title, + description, + children, + className, +}) => { + return ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
{children}
+
+ ); +}; + +interface DebugCodeBlockProps { + code: string; + className?: string; +} + +export const DebugCodeBlock: FC = ({ + code, + className, +}) => { + return ( +
+			{code}
+		
+ ); +}; + +// --------------------------------------------------------------------------- +// Copyable code block: code block with an inline copy button. +// --------------------------------------------------------------------------- + +interface CopyableCodeBlockProps { + code: string; + label: string; + className?: string; +} + +export const CopyableCodeBlock: FC = ({ + code, + label, + className, +}) => { + return ( +
+
+ +
+ +
+ ); +}; + +// --------------------------------------------------------------------------- +// Pill toggle: compact toggle button for optional metadata sections. +// --------------------------------------------------------------------------- + +interface PillToggleProps { + label: string; + count?: number; + isActive: boolean; + onToggle: () => void; + icon?: ReactNode; +} + +export const PillToggle: FC = ({ + label, + count, + isActive, + onToggle, + icon, +}) => { + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Role badge: role-colored badge for message transcripts. +// --------------------------------------------------------------------------- + +interface RoleBadgeProps { + role: string; +} + +export const RoleBadge: FC = ({ role }) => { + return ( + + {role} + + ); +}; + +// --------------------------------------------------------------------------- +// Empty helper: fallback message for absent data sections. +// --------------------------------------------------------------------------- + +interface EmptyHelperProps { + message: string; +} + +export const EmptyHelper: FC = ({ message }) => { + return

{message}

; +}; + +// --------------------------------------------------------------------------- +// Key-value grid: shared definition list for Options/Usage/Policy sections. +// --------------------------------------------------------------------------- + +interface KeyValueGridProps { + entries: Record; + /** Format value for display. Defaults to String(value). */ + formatValue?: (value: unknown) => string; +} + +export const KeyValueGrid: FC = ({ + entries, + formatValue, +}) => { + const fmt = + formatValue ?? + ((v: unknown) => + typeof v === "object" && v !== null ? safeJsonStringify(v) : String(v)); + + return ( +
+ {Object.entries(entries).map(([key, value]) => ( +
+
{key}
+
+ {fmt(value)} +
+
+ ))} +
+ ); +}; + +// --------------------------------------------------------------------------- +// Metadata item: compact label : value pair for metadata bars. +// --------------------------------------------------------------------------- + +interface MetadataItemProps { + label: string; + value: ReactNode; +} + +export const MetadataItem: FC = ({ label, value }) => { + return ( + + {label}:{" "} + {value} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx new file mode 100644 index 0000000000..97949d7553 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunCard.tsx @@ -0,0 +1,178 @@ +import { ChevronDownIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { useQuery } from "react-query"; +import { getErrorMessage } from "#/api/errors"; +import { chatDebugRun } from "#/api/queries/chats"; +import type { ChatDebugRunSummary } from "#/api/typesGenerated"; +import { Alert } from "#/components/Alert/Alert"; +import { Badge } from "#/components/Badge/Badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; +import { DebugStepCard } from "./DebugStepCard"; +import { + clampContent, + coerceRunSummary, + compactDuration, + computeDurationMs, + formatTokenSummary, + getRunKindLabel, + getStatusBadgeVariant, + isActiveStatus, +} from "./debugPanelUtils"; + +interface DebugRunCardProps { + run: ChatDebugRunSummary; + chatId: string; + isVisible: boolean; +} + +// Max characters shown in the run header label before truncation. +const RUN_LABEL_CLAMP_CHARS = 80; + +const getDurationLabel = (startedAt: string, finishedAt?: string): string => { + const durationMs = computeDurationMs(startedAt, finishedAt); + return durationMs !== null ? compactDuration(durationMs) : "-"; +}; + +export const DebugRunCard: FC = ({ + run, + chatId, + isVisible, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const runDetailQuery = useQuery({ + ...chatDebugRun(chatId, run.id), + enabled: isVisible && isExpanded, + }); + + const steps = runDetailQuery.data?.steps ?? []; + + // Coerce summary from detail (preferred) → props → empty. + const summaryVm = coerceRunSummary( + runDetailQuery.data?.summary ?? run.summary, + ); + const modelLabel = summaryVm.model?.trim() || run.model?.trim() || ""; + + // Primary label fallback chain: firstMessage → kind. + const primaryLabel = clampContent( + summaryVm.primaryLabel.trim() || getRunKindLabel(run.kind), + RUN_LABEL_CLAMP_CHARS, + ); + + // Token summary for the header. + const tokenLabel = formatTokenSummary( + summaryVm.totalInputTokens, + summaryVm.totalOutputTokens, + ); + + // Step count from detail or summary. + const stepCount = steps.length > 0 ? steps.length : summaryVm.stepCount; + const durationLabel = getDurationLabel(run.started_at, run.finished_at); + const metadataItems = [ + modelLabel || undefined, + stepCount !== undefined && stepCount > 0 + ? `${stepCount} ${stepCount === 1 ? "step" : "steps"}` + : undefined, + durationLabel, + tokenLabel || undefined, + ].filter((item) => item !== undefined); + // Prefer the detail query's status while the card is expanded so + // the badge and spinner flip to the final state as soon as the + // detail refetch observes the transition, rather than waiting for + // the list query to catch up on its own polling cycle. When the + // card is collapsed the detail query is disabled, so any cached + // `runDetailQuery.data` is stale; fall back to `run.status` from + // the list query in that case. + const effectiveStatus = isExpanded + ? (runDetailQuery.data?.status ?? run.status) + : run.status; + const running = isActiveStatus(effectiveStatus); + + return ( + +
+ + + + + {runDetailQuery.isLoading ? ( +
+ + Loading run details... +
+ ) : runDetailQuery.isError && !runDetailQuery.data ? ( + +

+ {getErrorMessage( + runDetailQuery.error, + "Unable to load debug run details.", + )} +

+
+ ) : ( +
+ {runDetailQuery.isError ? ( + +

+ {getErrorMessage( + runDetailQuery.error, + "Unable to refresh debug run details. Showing cached data.", + )} +

+
+ ) : null} + {steps.map((step) => ( + + ))} + {steps.length === 0 ? ( +

+ No steps recorded. +

+ ) : null} +
+ )} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx new file mode 100644 index 0000000000..0283c32d72 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugRunList.tsx @@ -0,0 +1,30 @@ +import type { FC } from "react"; +import type { ChatDebugRunSummary } from "#/api/typesGenerated"; +import { DebugRunCard } from "./DebugRunCard"; + +interface DebugRunListProps { + runs: ChatDebugRunSummary[]; + chatId: string; + isVisible: boolean; +} + +export const DebugRunList: FC = ({ + runs, + chatId, + isVisible, +}) => { + // Empty state is handled by DebugPanel before rendering this + // component. No guard here to avoid duplicated copy that drifts. + return ( +
+ {runs.map((run) => ( + + ))} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCard.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCard.tsx new file mode 100644 index 0000000000..e42aac1a84 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCard.tsx @@ -0,0 +1,435 @@ +import { ChevronDownIcon, WrenchIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { getErrorMessage } from "#/api/errors"; +import type { ChatDebugStep } from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { cn } from "#/utils/cn"; +import { DebugAttemptAccordion } from "./DebugAttemptAccordion"; +import { + CopyableCodeBlock, + DebugDataSection, + EmptyHelper, + KeyValueGrid, + MetadataItem, + PillToggle, +} from "./DebugPanelPrimitives"; +import { + MessageRow, + ToolBadge, + ToolEventCard, + ToolPayloadDisclosure, +} from "./DebugStepCardTooling"; +import { + coerceStepRequest, + coerceStepResponse, + coerceUsageRecord, + compactDuration, + computeDurationMs, + extractTokenCounts, + formatTokenSummary, + getStatusBadgeVariant, + normalizeAttempts, + safeJsonStringify, + TRANSCRIPT_PREVIEW_COUNT, +} from "./debugPanelUtils"; + +interface DebugStepCardProps { + step: ChatDebugStep; + defaultOpen?: boolean; +} + +type SectionKey = "tools" | "options" | "usage" | "policy"; + +export const DebugStepCard: FC = ({ + step, + defaultOpen = false, +}) => { + // Single active metadata pill: only one section open at a time. + const [activeSection, setActiveSection] = useState(null); + + // Transcript preview: show last N messages by default. + const [showAllMessages, setShowAllMessages] = useState(false); + + const toggleSection = (key: SectionKey) => { + setActiveSection((prev) => (prev === key ? null : key)); + }; + + // Coerce payloads defensively. + const request = coerceStepRequest(step.normalized_request); + const response = coerceStepResponse(step.normalized_response); + const stepUsage = coerceUsageRecord(step.usage); + const mergedUsage = + Object.keys(stepUsage).length > 0 ? stepUsage : response.usage; + const tokenCounts = extractTokenCounts(mergedUsage); + const tokenLabel = formatTokenSummary(tokenCounts.input, tokenCounts.output); + const normalizedAttempts = normalizeAttempts(step.attempts); + const attemptCount = normalizedAttempts.parsed.length; + + const durationMs = computeDurationMs(step.started_at, step.finished_at); + const durationLabel = durationMs !== null ? compactDuration(durationMs) : "-"; + + // Model: prefer request model, then response model. + const model = request.model ?? response.model; + + // Counts for pill badges. + const toolCount = request.tools.length; + const optionCount = Object.keys(request.options).length; + const usageEntryCount = Object.keys(mergedUsage).length; + const policyCount = Object.keys(request.policy).length; + const hasPills = + toolCount > 0 || optionCount > 0 || usageEntryCount > 0 || policyCount > 0; + + // Transcript preview slicing. + const totalMessages = request.messages.length; + const isTruncated = + !showAllMessages && totalMessages > TRANSCRIPT_PREVIEW_COUNT; + const visibleMessages = isTruncated + ? request.messages.slice(-TRANSCRIPT_PREVIEW_COUNT) + : request.messages; + const hiddenCount = totalMessages - visibleMessages.length; + + // Detect whether there is meaningful output. + const hasOutput = + !!response.content || + response.toolCalls.length > 0 || + response.warnings.length > 0 || + !!response.finishReason; + + // Detect whether there is an error payload. `step.error` is typed + // as an object but the runtime may deliver either a string or a + // non-empty object, so probe both shapes via an `unknown` view. + const rawError: unknown = step.error; + const isStringError = + typeof rawError === "string" && rawError.trim().length > 0; + const hasError = + isStringError || + (typeof rawError === "object" && + rawError !== null && + Object.keys(rawError).length > 0); + const errorText = getErrorMessage(rawError, safeJsonStringify(rawError)); + + return ( + +
+ + + + + + {/* ── Metadata bar ────────────────────────────── */} +
+ {model ? : null} + {request.options.max_output_tokens !== undefined ? ( + + ) : null} + {request.policy.tool_choice !== undefined ? ( + { + const tc = request.policy.tool_choice; + if (tc == null) return ""; + if (typeof tc === "string") return tc; + try { + return JSON.stringify(tc); + } catch { + return String(tc); + } + })()} + /> + ) : null} + {attemptCount > 0 ? ( + + {attemptCount} {attemptCount === 1 ? "attempt" : "attempts"} + + ) : null} +
+ + {/* ── Pill toggles (single active) ───────────── */} + {hasPills ? ( +
+ {toolCount > 0 ? ( + toggleSection("tools")} + icon={} + /> + ) : null} + {optionCount > 0 ? ( + toggleSection("options")} + /> + ) : null} + {usageEntryCount > 0 ? ( + toggleSection("usage")} + /> + ) : null} + {policyCount > 0 ? ( + toggleSection("policy")} + /> + ) : null} +
+ ) : null} + + {/* ── Active metadata section ────────────────── */} + {activeSection === "tools" && toolCount > 0 ? ( +
+ {request.tools.map((tool) => ( +
+ + {tool.description ? ( +

+ {tool.description} +

+ ) : null} + +
+ ))} +
+ ) : null} + + {activeSection === "options" && optionCount > 0 ? ( + + + + ) : null} + + {activeSection === "usage" && usageEntryCount > 0 ? ( + + + typeof v === "number" ? v.toLocaleString("en-US") : String(v) + } + /> + + ) : null} + + {activeSection === "policy" && policyCount > 0 ? ( + + + + ) : null} + + {/* ── Input / Output sections ──────────────────── */} +
+ {/* ── Input column ────────────────────────── */} + + {totalMessages > 0 ? ( +
+ {hiddenCount > 0 ? ( + + ) : null} + + {showAllMessages && + totalMessages > TRANSCRIPT_PREVIEW_COUNT ? ( + + ) : null} + + {visibleMessages.map((msg, idx) => ( + + ))} +
+ ) : ( + + )} +
+ + {/* ── Output column ───────────────────────── */} + + {hasOutput ? ( +
+ {/* Primary response content: visually prominent. */} + {response.content ? ( +

+ {response.content} +

+ ) : null} + + {/* Tool calls: structured cards with arguments. */} + {response.toolCalls.length > 0 ? ( +
+ {response.toolCalls.map((tc, idx) => ( + + ))} +
+ ) : null} + + {/* Secondary metadata: finish reason + warnings. */} + {response.finishReason ? ( + + Finish: {response.finishReason} + + ) : null} + {response.warnings.length > 0 ? ( +
+ {response.warnings.map((w, idx) => ( +

+ {" "} + Warning: + {w} +

+ ))} +
+ ) : null} +
+ ) : ( + + )} +
+
+ + {/* ── Error ───────────────────────────────────── */} + {hasError ? ( + + + + ) : null} + + {/* ── Request body JSON (lower priority) ─────── */} + + + + + + + + + + {/* ── Response body JSON ──────────────────────── */} + {step.normalized_response ? ( + + + + + + + + + ) : null} + + {/* ── Raw HTTP attempts ───────────────────────── */} + {attemptCount > 0 || + (normalizedAttempts.rawFallback && + normalizedAttempts.rawFallback !== "{}" && + normalizedAttempts.rawFallback !== "[]") ? ( + + + + ) : null} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCardTooling.tsx b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCardTooling.tsx new file mode 100644 index 0000000000..d05dd00775 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/DebugStepCardTooling.tsx @@ -0,0 +1,169 @@ +import { WrenchIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { Badge } from "#/components/Badge/Badge"; +import { cn } from "#/utils/cn"; +import { CopyableCodeBlock, RoleBadge } from "./DebugPanelPrimitives"; +import { + clampContent, + exceedsClampThreshold, + MESSAGE_CONTENT_CLAMP_CHARS, + type MessagePart, +} from "./debugPanelUtils"; + +interface MessageRowProps { + msg: MessagePart; + clamp: boolean; +} + +interface ToolPayloadDisclosureProps { + label: string; + code?: string; + copyLabel: string; +} + +export const ToolPayloadDisclosure: FC = ({ + label, + code, + copyLabel, +}) => { + if (!code) { + return null; + } + + return ( +
+

+ {label} +

+ +
+ ); +}; + +export const ToolBadge: FC<{ label: string }> = ({ label }) => { + return ( + + + {label} + + ); +}; + +interface ToolEventCardProps { + badgeLabel: string; + toolCallId?: string; + payloadLabel?: string; + payload?: string; + copyLabel?: string; +} + +export const ToolEventCard: FC = ({ + badgeLabel, + toolCallId, + payloadLabel, + payload, + copyLabel, +}) => { + return ( +
+
+ + {toolCallId ? ( + + {toolCallId} + + ) : null} +
+ {payloadLabel && payload && copyLabel ? ( + + ) : null} +
+ ); +}; + +const TranscriptToolRow: FC<{ msg: MessagePart }> = ({ msg }) => { + const isToolCall = msg.kind === "tool-call"; + const badgeLabel = msg.toolName ?? (isToolCall ? "Tool call" : "Tool result"); + const payloadLabel = isToolCall ? "Arguments" : "Result"; + const payload = isToolCall ? msg.arguments : msg.result; + + return ( +
+
+ +
+ +
+ ); +}; + +const TranscriptTextRow: FC = ({ msg, clamp }) => { + const [expanded, setExpanded] = useState(false); + // Use the same code-point count as clampContent so the "see more" + // control never appears when the message is short enough that + // clampContent would return it unchanged. + const needsClamp = + clamp && exceedsClampThreshold(msg.content, MESSAGE_CONTENT_CLAMP_CHARS); + const showClamped = needsClamp && !expanded; + const displayContent = showClamped + ? clampContent(msg.content, MESSAGE_CONTENT_CLAMP_CHARS) + : msg.content; + + return ( +
+
+ + {msg.toolName ? ( + + {msg.toolName} + + ) : null} + {msg.toolCallId && !msg.toolName ? ( + + {msg.toolCallId} + + ) : null} +
+ {displayContent ? ( + <> +

+ {displayContent} +

+ {needsClamp ? ( + + ) : null} + + ) : null} +
+ ); +}; + +export const MessageRow: FC = ({ msg, clamp }) => { + if (msg.kind === "tool-call" || msg.kind === "tool-result") { + return ; + } + + return ; +}; diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts index 3abb1e369e..0b0866d645 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it, vi } from "vitest"; import { clampContent, coerceRunSummary, @@ -6,6 +7,7 @@ import { coerceUsageRecord, compactDuration, computeDurationMs, + exceedsClampThreshold, extractTokenCounts, formatTokenSummary, getRoleBadgeVariant, @@ -13,9 +15,122 @@ import { getStatusBadgeVariant, isActiveStatus, normalizeAttempts, + safeJsonStringify, } from "./debugPanelUtils"; +describe("safeJsonStringify", () => { + it("returns strings unchanged", () => { + expect(safeJsonStringify("hello")).toBe("hello"); + }); + + it("pretty-prints JSON for objects and arrays", () => { + expect(safeJsonStringify({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + + it("returns an empty string for undefined instead of the value undefined", () => { + const result = safeJsonStringify(undefined); + expect(typeof result).toBe("string"); + expect(result).toBe(""); + }); + + it("falls back to String(value) when JSON.stringify yields undefined", () => { + // Functions are skipped by JSON.stringify at the top level, so the + // fallback must hand back a meaningful string representation + // (String(fn) returns the function source, which contains "noop"). + const result = safeJsonStringify(() => "noop"); + expect(typeof result).toBe("string"); + expect(result).toContain("noop"); + }); + + it("falls back to String(value) when JSON.stringify throws on circular refs", () => { + // JSON.stringify throws TypeError on circular references; the + // catch branch is the panel's last line of defense against a + // payload that would otherwise crash rendering. Without this + // coverage a refactor could silently drop the catch without + // breaking any test. + type Node = { self?: Node }; + const circular: Node = {}; + circular.self = circular; + const result = safeJsonStringify(circular); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); +}); + +describe("clampContent", () => { + it("returns the input unchanged when shorter than the limit", () => { + expect(clampContent("hello", 10)).toBe("hello"); + }); + + it("trims whitespace before measuring length", () => { + expect(clampContent(" hi ", 5)).toBe("hi"); + }); + + it("truncates at code-point boundaries so surrogate pairs stay intact", () => { + // 79 one-code-point chars + one two-UTF-16-unit emoji = 80 code + // points (and 81 UTF-16 code units). A plain String.slice at 80 + // would split the surrogate pair; Array.from keeps it whole. + const input = `${"a".repeat(79)}🎉extra`; + const clamped = clampContent(input, 80); + expect(clamped.endsWith("🎉…")).toBe(true); + // No stray lone surrogates should remain in the output. + for (let i = 0; i < clamped.length; i++) { + const code = clamped.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + // high surrogate must be followed by a low surrogate + const next = clamped.charCodeAt(i + 1); + expect(next >= 0xdc00 && next <= 0xdfff).toBe(true); + } + } + }); + + it("appends an ellipsis when truncating", () => { + expect(clampContent("abcdefghij", 4)).toBe("abcd…"); + }); + + it("returns an empty string for whitespace-only input", () => { + expect(clampContent(" ", 10)).toBe(""); + }); + + it("keeps text exactly at the limit unchanged", () => { + expect(clampContent("abcde", 5)).toBe("abcde"); + }); + + it("strips trailing whitespace before appending the ellipsis", () => { + expect(clampContent("abc defghij", 6)).toBe("abc…"); + }); +}); + +describe("exceedsClampThreshold", () => { + it("returns false when the trimmed content fits", () => { + expect(exceedsClampThreshold(" short ", 10)).toBe(false); + }); + + it("returns false when UTF-16 length exceeds but code-point count fits", () => { + // A string of 80 emoji is 160 UTF-16 units but 80 code points. + const emoji = "🎉".repeat(80); + expect(exceedsClampThreshold(emoji, 80)).toBe(false); + }); + + it("returns true when code-point count exceeds the limit", () => { + expect(exceedsClampThreshold("🎉".repeat(81), 80)).toBe(true); + }); +}); + describe("coerceStepResponse", () => { + it("passes through plain string content unchanged", () => { + // Simple text completions arrive as a top-level `content: string` + // without any array/choices wrapper. The string branch is a real + // production path and must preserve the text verbatim. + const response = coerceStepResponse({ + content: "hello world", + }); + + expect(response.content).toBe("hello world"); + expect(response.toolCalls).toEqual([]); + expect(response.finishReason).toBeUndefined(); + }); + it("keeps tool-result content emitted in normalized response parts", () => { const response = coerceStepResponse({ content: [ @@ -903,26 +1018,51 @@ describe("coerceStepRequest", () => { expect(request.options).toEqual({ temperature: 0.5 }); expect(request.policy).toEqual({ tool_choice: "none" }); }); -}); -describe("clampContent", () => { - it("returns the trimmed text when under the limit", () => { - expect(clampContent(" hello ", 20)).toBe("hello"); + it("canonicalizes camelCase option aliases to snake_case", () => { + const request = coerceStepRequest({ + messages: [], + tools: [], + options: { + maxOutputTokens: 2048, + topP: 0.9, + }, + policy: { + toolChoice: "auto", + }, + }); + + expect(request.options).toEqual({ + max_output_tokens: 2048, + top_p: 0.9, + }); + expect(request.policy).toEqual({ + tool_choice: "auto", + }); }); - it("truncates and appends an ellipsis when over the limit", () => { - expect(clampContent("hello world", 5)).toBe("hello…"); + it("prefers the snake_case key when both variants are present", () => { + const request = coerceStepRequest({ + options: { + max_output_tokens: 1024, + maxOutputTokens: 2048, + }, + }); + + expect(request.options).toEqual({ max_output_tokens: 1024 }); }); - it("returns an empty string for whitespace-only input", () => { - expect(clampContent(" ", 10)).toBe(""); - }); + // OpenAI completions historically used `max_tokens` as the token-limit + // field name. Pin it as a standalone alias so removing it from the + // canonicalization list breaks a test instead of silently dropping the + // field when upstream responses still speak the legacy key. + it("canonicalizes the OpenAI `max_tokens` alias to max_output_tokens", () => { + const request = coerceStepRequest({ + options: { + max_tokens: 512, + }, + }); - it("keeps text exactly at the limit unchanged", () => { - expect(clampContent("abcde", 5)).toBe("abcde"); - }); - - it("strips trailing whitespace before appending the ellipsis", () => { - expect(clampContent("abc defghij", 6)).toBe("abc…"); + expect(request.options).toEqual({ max_output_tokens: 512 }); }); }); diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts index cedc18637b..9a9e570364 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts @@ -24,7 +24,12 @@ const RUN_KIND_LABELS: Record = { tool_call: "Tool Call", }; -const SUCCESS_STATUSES = new Set(["completed", "success", "succeeded", "ok"]); +export const SUCCESS_STATUSES = new Set([ + "completed", + "success", + "succeeded", + "ok", +]); const WARNING_STATUSES = new Set([ "pending", "queued", @@ -37,7 +42,7 @@ const INFO_STATUSES = new Set([ "processing", "started", ]); -const ERROR_STATUSES = new Set([ +export const ERROR_STATUSES = new Set([ "failed", "error", "errored", @@ -93,13 +98,22 @@ const humanizeToken = (value: string): string => { .replace(/\b\w/g, (match) => match.toUpperCase()); }; -const safeJsonStringify = (value: unknown): string => { +export const safeJsonStringify = (value: unknown): string => { if (typeof value === "string") { return value; } + if (value === undefined) { + // JSON.stringify(undefined) returns undefined (not a string), which + // would break the string return contract and surface "undefined" in + // the debug panel as literal text from String(undefined). + return ""; + } try { - return JSON.stringify(value, null, 2); + const serialized = JSON.stringify(value, null, 2); + // JSON.stringify returns undefined for values like functions or + // symbols. Fall back to String() so the caller always gets a string. + return serialized ?? String(value); } catch { return String(value); } @@ -810,44 +824,41 @@ const coerceToolCalls = (value: unknown): ToolCallPart[] => { // Known option / policy field extraction. // --------------------------------------------------------------------------- -const OPTION_KEYS: readonly string[] = [ - "temperature", - "top_p", - "topP", - "top_k", - "topK", - "max_output_tokens", - "maxOutputTokens", - "max_tokens", - "maxTokens", - "frequency_penalty", - "frequencyPenalty", - "presence_penalty", - "presencePenalty", - "seed", - "stop", +// Option/policy keys are expressed as `[canonical, ...aliases]`. AI +// providers mix snake_case and camelCase for the same concept; we +// canonicalize to snake_case here so downstream renderers don't have to +// check both variants (and so the key-value grid never shows duplicate +// rows for the same value). +const OPTION_KEYS: ReadonlyArray = [ + ["temperature"], + ["top_p", "topP"], + ["top_k", "topK"], + ["max_output_tokens", "maxOutputTokens", "max_tokens", "maxTokens"], + ["frequency_penalty", "frequencyPenalty"], + ["presence_penalty", "presencePenalty"], + ["seed"], + ["stop"], ]; -const POLICY_KEYS: readonly string[] = [ - "tool_choice", - "toolChoice", - "response_format", - "responseFormat", - "structured_output", - "structuredOutput", - "parallel_tool_calls", - "parallelToolCalls", +const POLICY_KEYS: ReadonlyArray = [ + ["tool_choice", "toolChoice"], + ["response_format", "responseFormat"], + ["structured_output", "structuredOutput"], + ["parallel_tool_calls", "parallelToolCalls"], ]; const extractKnownFields = ( obj: Record, - keys: readonly string[], + keys: ReadonlyArray, ): Record => { const result: Record = {}; - for (const key of keys) { - const value = obj[key]; - if (value !== undefined && value !== null) { - result[key] = deepParse(value); + for (const [canonical, ...aliases] of keys) { + for (const candidate of [canonical, ...aliases]) { + const value = obj[candidate]; + if (value !== undefined && value !== null) { + result[canonical] = deepParse(value); + break; + } } } return result; @@ -1237,7 +1248,31 @@ export const clampContent = (text: string, maxLen: number): string => { if (trimmed.length <= maxLen) { return trimmed; } - return `${trimmed.slice(0, maxLen).trimEnd()}…`; + // Use Array.from to split on code points rather than UTF-16 code + // units. A plain String.slice can cut a surrogate pair in half, + // producing a lone high surrogate rendered as U+FFFD. + const codePoints = Array.from(trimmed); + if (codePoints.length <= maxLen) { + return trimmed; + } + return `${codePoints.slice(0, maxLen).join("").trimEnd()}…`; +}; + +/** + * Returns true when the text is long enough that clampContent would + * actually truncate it. Uses the same trim+code-point count so callers + * never offer a "see more" control for text that clampContent returns + * unchanged. + */ +export const exceedsClampThreshold = ( + text: string, + maxLen: number, +): boolean => { + const trimmed = text.trim(); + if (trimmed.length <= maxLen) { + return false; + } + return Array.from(trimmed).length > maxLen; }; // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx index d76979dcea..09ea2b70fa 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx @@ -36,7 +36,7 @@ const meta: Meta = { component: SidebarTabView, args: { tabs: [gitTab], - activeTabId: "git", + effectiveTabId: "git", onActiveTabChange: fn(), isExpanded: false, onToggleExpanded: fn(), diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx index 9c6d731c64..28e8cfa933 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx @@ -42,8 +42,14 @@ interface SidebarTabViewProps { onClose?: () => void; /** Desktop chat ID. Omitted if desktop is not available. */ desktopChatId?: string; - /** The currently active tab ID (controlled by the parent). */ - activeTabId: string | null; + /** + * The resolved tab ID to render as active (computed by the parent + * with `getEffectiveTabId`). Keeping a single source of truth in the + * parent prevents this component's highlight from drifting from + * parent-side gating like `TerminalPanel.isVisible` or + * `DebugPanel.isVisible`. + */ + effectiveTabId: string | null; /** Called when the user switches tabs. */ onActiveTabChange: (tabId: string) => void; } @@ -111,27 +117,10 @@ export const SidebarTabView: FC = ({ chatTitle, onClose, desktopChatId, - activeTabId, + effectiveTabId, onActiveTabChange, }) => { const tabIdPrefix = useId(); - // Build the full list of tab IDs including the desktop tab - // so that effectiveTabId validation covers it. - const allTabIds = new Set(tabs.map((t) => t.id)); - if (desktopChatId) { - allTabIds.add("desktop"); - } - - // Derive the effective tab. Fall back to the first tab if - // the stored activeTabId no longer matches any tab in the list. - const effectiveTabId = - activeTabId !== null && allTabIds.has(activeTabId) - ? activeTabId - : tabs.length > 0 - ? tabs[0].id - : desktopChatId - ? "desktop" - : null; // Unified list of panels for rendering. Includes the desktop // tab when available so we don't need to special-case it. diff --git a/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts b/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts new file mode 100644 index 0000000000..8efdef4589 --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { getEffectiveTabId } from "./getEffectiveTabId"; + +describe("getEffectiveTabId", () => { + it("returns the active tab id when it matches a known tab", () => { + expect( + getEffectiveTabId(["git", "terminal", "debug"], "debug", undefined), + ).toBe("debug"); + }); + + it("falls back to the first tab when the active id is unknown", () => { + expect(getEffectiveTabId(["git", "terminal"], "missing", undefined)).toBe( + "git", + ); + }); + + it("falls back to the first tab when no active id is set", () => { + expect(getEffectiveTabId(["git", "terminal"], null, undefined)).toBe("git"); + }); + + it("resolves to desktop when it is the active id and desktopChatId is set", () => { + expect(getEffectiveTabId(["git"], "desktop", "desktop-123")).toBe( + "desktop", + ); + }); + + it("returns desktop when the tab list is empty but desktop is available", () => { + expect(getEffectiveTabId([], null, "desktop-123")).toBe("desktop"); + }); + + it("returns null when no tabs are available", () => { + expect(getEffectiveTabId([], null, undefined)).toBeNull(); + }); + + it("ignores an unknown active id when only desktop is available", () => { + expect(getEffectiveTabId([], "git", "desktop-123")).toBe("desktop"); + }); +}); diff --git a/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts b/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts new file mode 100644 index 0000000000..16d6baf310 --- /dev/null +++ b/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts @@ -0,0 +1,32 @@ +/** + * Resolves which sidebar tab should be active given the set of + * available tab IDs, the currently stored selection, and whether + * the desktop chat tab is available. + * + * Precedence: + * 1. `activeTabId` when it matches a known tab. + * 2. The first entry in `tabIds` (ordered array, not a Set). + * 3. `"desktop"` when `desktopChatId` is truthy. + * 4. `null` (no valid tab available). + * + * AgentChatPageView owns this resolution so the parent-side gating + * (e.g. `TerminalPanel.isVisible`, `DebugPanel.isVisible`) and the + * child SidebarTabView's visual highlight always agree. The child + * receives the resolved value via the `effectiveTabId` prop. + */ +export function getEffectiveTabId( + tabIds: readonly string[], + activeTabId: string | null, + desktopChatId: string | undefined, +): string | null { + const allIds = new Set(tabIds); + if (desktopChatId) { + allIds.add("desktop"); + } + + if (activeTabId !== null && allIds.has(activeTabId)) { + return activeTabId; + } + + return tabIds[0] ?? (desktopChatId ? "desktop" : null); +}