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); +}