feat(site): add Debug panel components and settings (#23920)

## 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

<img width="4608" height="2348" alt="CleanShot 2026-04-21 at 21 19 36@2x" src="https://github.com/user-attachments/assets/69391465-4c56-468a-9923-59576d326963" />

Conditional Debug tab

<img width="4608" height="2348" alt="CleanShot 2026-04-21 at 21 19 58@2x" src="https://github.com/user-attachments/assets/bc1e07cb-21d9-40e7-8928-6fd9a7ec7f57" />

Last request's tools and schema 

<img width="4608" height="2348" alt="CleanShot 2026-04-21 at 21 20 03@2x" src="https://github.com/user-attachments/assets/401f26af-98ce-443f-a586-424d3636d98b" />

"Raw" JSON request bodies

<img width="4608" height="2348" alt="CleanShot 2026-04-21 at 21 20 35@2x" src="https://github.com/user-attachments/assets/3605a373-9e29-4183-89e7-8b2704ff9333" />


### 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`_
This commit is contained in:
Thomas Kosiewski
2026-04-22 15:34:32 +02:00
committed by GitHub
parent 8c0fe6d5f2
commit 249b71b96a
22 changed files with 2003 additions and 126 deletions
+33
View File
@@ -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);
}
});
});
+74 -5
View File
@@ -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({
@@ -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}
@@ -138,6 +138,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
diffStatusData: undefined as ComponentProps<
typeof AgentChatPageView
>["diffStatusData"],
debugLoggingEnabled: false,
gitWatcher: buildGitWatcher(),
sshCommand: undefined as string | undefined,
handleCommit: fn(),
+76 -45
View File
@@ -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<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => boolean;
@@ -205,6 +208,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onSetShowSidebarPanel,
prNumber,
diffStatusData,
debugLoggingEnabled,
gitWatcher,
sshCommand,
handleCommit,
@@ -275,6 +279,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
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<AgentChatPageViewProps> = ({
};
})();
// 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 (
<GitPanel
prTab={
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
}
repositories={gitWatcher.repositories}
onRefresh={handleRefresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
);
case "terminal":
return workspace && workspaceAgent ? (
<TerminalPanel
chatId={agentId}
isVisible={
shouldShowSidebar && effectiveSidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
) : null;
case "debug":
return (
<DebugPanel
chatId={agentId}
isVisible={shouldShowSidebar && effectiveSidebarTabId === "debug"}
/>
);
default:
return null;
}
};
const sidebarTabs = sidebarTabConfigs.map((tab) => ({
id: tab.id,
label: tab.label,
content: renderTabContent(tab.id),
}));
const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
</title>
);
const shouldShowSidebar = showSidebarPanel;
return (
<ChatWorkspaceContext
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
@@ -457,56 +528,16 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
activeTabId={sidebarTabId}
effectiveTabId={effectiveSidebarTabId}
onActiveTabChange={setSidebarTabId}
tabs={[
{
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={handleRefresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
),
},
...(workspace && workspaceAgent
? [
{
id: "terminal",
label: "Terminal",
content: (
<TerminalPanel
chatId={agentId}
isVisible={
shouldShowSidebar && sidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
),
},
]
: []),
]}
tabs={sidebarTabs}
onClose={() => onSetShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
desktopChatId={
workspace && workspaceAgent ? desktopChatId : undefined
}
desktopChatId={availableDesktopChatId}
/>
</RightPanel>
</div>
@@ -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}
@@ -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<
<div className="flex flex-col gap-8">
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your conversations."
description="Custom instructions that shape how the agent responds in your conversations, plus debug controls for inspecting model traffic."
/>
<PersonalInstructionsSettings
userPromptData={userPromptData}
@@ -147,6 +172,17 @@ export const AgentSettingsBehaviorPageView: FC<
isAnyPromptSaving={isAnyPromptSaving}
/>
<ChatFullWidthSettings />
<DebugLoggingSettings
canManageAdminSetting={canSetSystemPrompt}
adminSettings={debugLoggingData}
userSettings={userDebugLoggingData}
onSaveAdminSetting={onSaveDebugLogging}
isSavingAdminSetting={isSavingDebugLogging}
isSaveAdminSettingError={isSaveDebugLoggingError}
onSaveUserSetting={onSaveUserDebugLogging}
isSavingUserSetting={isSavingUserDebugLogging}
isSaveUserSettingError={isSaveUserDebugLoggingError}
/>
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
@@ -168,6 +168,15 @@ const BehaviorRouteElement = () => {
}}
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();
});
@@ -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<DebugLoggingSettingsProps> = ({
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 (
<div className="space-y-4">
{canManageAdminSetting && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Let users record chat debug logs
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
Debug logging is already enabled deployment-wide, so this
per-user setting has no effect right now.
</p>
) : (
<p className="m-0">
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.
</p>
)}
</div>
<Switch
checked={adminAllowsUsers}
onCheckedChange={(checked) =>
onSaveAdminSetting({ allow_users: checked })
}
aria-label="Allow users to enable chat debug logging"
disabled={forcedByDeployment || isSavingAdminSetting}
/>
</div>
{isSaveAdminSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save the admin debug logging setting.
</p>
)}
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Record debug logs for my chats
</h3>
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
{forcedByDeployment ? (
<p className="m-0">
An administrator has enabled debug logging for every chat in
this deployment, so this toggle is locked on.
</p>
) : userToggleAllowed ? (
<p className="m-0">
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.
</p>
) : (
<p className="m-0">
An administrator hasn't allowed users to record chat debug logs
yet.
</p>
)}
</div>
<Switch
checked={forcedByDeployment || userDebugLoggingEnabled}
onCheckedChange={(checked) =>
onSaveUserSetting({ debug_logging_enabled: checked })
}
aria-label="Enable personal chat debug logging"
disabled={
forcedByDeployment || !userToggleAllowed || isSavingUserSetting
}
/>
</div>
{isSaveUserSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save your chat debug logging preference.
</p>
)}
</div>
</div>
);
};
@@ -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<JsonBlockProps> = ({ value, fallbackCopy }) => {
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.length === 0) ||
(typeof value === "object" && Object.keys(value as object).length === 0)
) {
return <EmptyHelper message={fallbackCopy} />;
}
if (typeof value === "string") {
return <DebugCodeBlock code={value} />;
}
return <DebugCodeBlock code={safeJsonStringify(value)} />;
};
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<DebugAttemptAccordionProps> = ({
attempts,
rawFallback,
}) => {
if (rawFallback) {
// No DebugDataSection wrapper here. The parent already
// wraps us in <DebugDataSection title="Raw attempts">.
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs text-content-secondary">
Unable to parse raw attempts. Showing the original payload exactly as
it was captured.
</p>
<DebugCodeBlock code={rawFallback} />
</div>
);
}
if (attempts.length === 0) {
return (
<p className="text-sm text-content-secondary">No attempts captured.</p>
);
}
return (
<div className="space-y-3">
{attempts.map((attempt, index) => (
<Collapsible
key={`${attempt.attempt_number}-${attempt.started_at ?? index}`}
defaultOpen={false}
>
<div className="border-l border-l-border-default/50">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-start gap-3 border-0 bg-transparent px-4 py-3 text-left transition-colors hover:bg-surface-secondary/20"
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-content-primary">
Attempt {attempt.attempt_number}
</span>
{attempt.method || attempt.path ? (
<span className="truncate font-mono text-xs font-medium text-content-secondary">
{[attempt.method, attempt.path]
.filter(Boolean)
.join(" ")}
</span>
) : null}
{attempt.response_status ? (
<Badge
size="xs"
variant={
attempt.response_status < 400
? "green"
: "destructive"
}
>
{attempt.response_status}
</Badge>
) : null}
<Badge
size="sm"
variant={getStatusBadgeVariant(attempt.status)}
className="shrink-0 sm:hidden"
>
{attempt.status || "unknown"}
</Badge>
</div>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-content-secondary">
<span>{getAttemptTimingLabel(attempt)}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
size="sm"
variant={getStatusBadgeVariant(attempt.status)}
className="hidden shrink-0 sm:inline-flex"
>
{attempt.status || "unknown"}
</Badge>
<ChevronDownIcon
className={cn(
"mt-0.5 size-4 shrink-0 text-content-secondary transition-transform",
"group-data-[state=open]:rotate-180",
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4 pt-2">
<div className="space-y-3">
<DebugDataSection title="Raw request">
<JsonBlock
value={attempt.raw_request}
fallbackCopy="No raw request captured."
/>
</DebugDataSection>
<DebugDataSection title="Raw response">
<JsonBlock
value={attempt.raw_response}
fallbackCopy="No raw response captured."
/>
</DebugDataSection>
<DebugDataSection title="Error">
<JsonBlock
value={attempt.error}
fallbackCopy="No error captured."
/>
</DebugDataSection>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
);
};
@@ -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<DebugPanelProps> = ({
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 ? (
<div className="p-4 pb-0">
<Alert severity="warning">
<p className="text-sm text-content-primary">
{getErrorMessage(
runsQuery.error,
"Unable to refresh debug runs. Showing cached data.",
)}
</p>
</Alert>
</div>
) : null;
let content: ReactNode;
if (runsQuery.isError && !hasRunsData) {
content = (
<div className="p-4">
<Alert severity="error" prominent>
<p className="text-sm text-content-primary">
{getErrorMessage(
runsQuery.error,
"Unable to load debug panel data.",
)}
</p>
</Alert>
</div>
);
} else if (runsQuery.isLoading) {
content = (
<div className="flex items-center gap-2 p-4 text-sm text-content-secondary">
<Spinner size="sm" loading />
Loading debug runs...
</div>
);
} else if (sortedRuns.length === 0) {
content = (
<>
{refreshWarning}
<div className="flex flex-col gap-2 p-4 text-sm text-content-secondary">
<p className="font-medium text-content-primary">
No debug runs recorded yet
</p>
<p>
Debug logging captures LLM request/response data for each chat turn,
title generation, and compaction operation.
</p>
<p>Send a message in this chat to start capturing debug data.</p>
</div>
</>
);
} else {
content = (
<>
{refreshWarning}
<DebugRunList runs={sortedRuns} chatId={chatId} isVisible={isVisible} />
</>
);
}
return (
<ScrollArea
className="h-full"
viewportClassName="h-full [&>div]:!block [&>div]:!w-full"
scrollBarClassName="w-1.5"
>
<div className="min-h-full w-full min-w-0 overflow-x-hidden">
{content}
</div>
</ScrollArea>
);
};
@@ -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<DebugDataSectionProps> = ({
title,
description,
children,
className,
}) => {
return (
<section className={cn("space-y-1.5", className)}>
<h4 className="text-xs font-medium text-content-secondary">{title}</h4>
{description ? (
<p className="text-xs leading-5 text-content-tertiary">{description}</p>
) : null}
<div>{children}</div>
</section>
);
};
interface DebugCodeBlockProps {
code: string;
className?: string;
}
export const DebugCodeBlock: FC<DebugCodeBlockProps> = ({
code,
className,
}) => {
return (
<pre
className={cn(
"w-full max-w-full max-h-[28rem] overflow-auto rounded-lg bg-surface-tertiary/60 px-3 py-2.5 font-mono text-[12px] leading-5 text-content-primary shadow-inner",
className,
)}
>
<code>{code}</code>
</pre>
);
};
// ---------------------------------------------------------------------------
// Copyable code block: code block with an inline copy button.
// ---------------------------------------------------------------------------
interface CopyableCodeBlockProps {
code: string;
label: string;
className?: string;
}
export const CopyableCodeBlock: FC<CopyableCodeBlockProps> = ({
code,
label,
className,
}) => {
return (
<div className="relative">
<div className="absolute right-2 top-2 z-10">
<CopyButton text={code} label={label} />
</div>
<DebugCodeBlock code={code} className={cn("pr-10", className)} />
</div>
);
};
// ---------------------------------------------------------------------------
// 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<PillToggleProps> = ({
label,
count,
isActive,
onToggle,
icon,
}) => {
return (
<button
type="button"
aria-pressed={isActive}
className={cn(
"inline-flex items-center gap-1 rounded-full border-0 px-2.5 py-0.5 text-2xs font-medium transition-colors",
isActive
? "bg-surface-secondary text-content-primary"
: "bg-transparent text-content-secondary hover:text-content-primary hover:bg-surface-secondary/50",
)}
onClick={onToggle}
>
{icon}
{label}
{count !== undefined && count > 0 ? ` (${count})` : null}
</button>
);
};
// ---------------------------------------------------------------------------
// Role badge: role-colored badge for message transcripts.
// ---------------------------------------------------------------------------
interface RoleBadgeProps {
role: string;
}
export const RoleBadge: FC<RoleBadgeProps> = ({ role }) => {
return (
<Badge size="xs" variant={getRoleBadgeVariant(role)}>
{role}
</Badge>
);
};
// ---------------------------------------------------------------------------
// Empty helper: fallback message for absent data sections.
// ---------------------------------------------------------------------------
interface EmptyHelperProps {
message: string;
}
export const EmptyHelper: FC<EmptyHelperProps> = ({ message }) => {
return <p className="text-sm leading-6 text-content-secondary">{message}</p>;
};
// ---------------------------------------------------------------------------
// Key-value grid: shared definition list for Options/Usage/Policy sections.
// ---------------------------------------------------------------------------
interface KeyValueGridProps {
entries: Record<string, unknown>;
/** Format value for display. Defaults to String(value). */
formatValue?: (value: unknown) => string;
}
export const KeyValueGrid: FC<KeyValueGridProps> = ({
entries,
formatValue,
}) => {
const fmt =
formatValue ??
((v: unknown) =>
typeof v === "object" && v !== null ? safeJsonStringify(v) : String(v));
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
{Object.entries(entries).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-content-tertiary">{key}</dt>
<dd className="break-words font-medium text-content-primary">
{fmt(value)}
</dd>
</div>
))}
</dl>
);
};
// ---------------------------------------------------------------------------
// Metadata item: compact label : value pair for metadata bars.
// ---------------------------------------------------------------------------
interface MetadataItemProps {
label: string;
value: ReactNode;
}
export const MetadataItem: FC<MetadataItemProps> = ({ label, value }) => {
return (
<span className="text-xs text-content-secondary">
<span className="text-content-tertiary">{label}:</span>{" "}
<span className="font-medium text-content-primary">{value}</span>
</span>
);
};
@@ -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<DebugRunCardProps> = ({
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 (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="overflow-hidden rounded-lg border border-solid border-border-default/40">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-0.5 text-left transition-colors hover:bg-surface-secondary/20"
>
<div className="min-w-0 flex flex-1 items-center gap-2.5 overflow-hidden">
<p className="min-w-0 flex-1 truncate text-sm font-semibold text-content-primary">
{primaryLabel}
</p>
<div className="flex shrink-0 items-center gap-2 text-xs leading-5 text-content-secondary">
{metadataItems.map((item, index) => (
<span
key={`${item}-${index}`}
className="shrink-0 whitespace-nowrap"
>
{item}
</span>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{running ? <Spinner size="sm" loading /> : null}
<Badge
size="sm"
variant={getStatusBadgeVariant(effectiveStatus)}
className="shrink-0"
>
{effectiveStatus || "unknown"}
</Badge>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-content-secondary transition-transform",
"group-data-[state=open]:rotate-180",
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 pt-1">
{runDetailQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner size="sm" loading />
Loading run details...
</div>
) : runDetailQuery.isError && !runDetailQuery.data ? (
<Alert severity="error" prominent>
<p className="text-sm text-content-primary">
{getErrorMessage(
runDetailQuery.error,
"Unable to load debug run details.",
)}
</p>
</Alert>
) : (
<div className="space-y-2">
{runDetailQuery.isError ? (
<Alert severity="warning">
<p className="text-sm text-content-primary">
{getErrorMessage(
runDetailQuery.error,
"Unable to refresh debug run details. Showing cached data.",
)}
</p>
</Alert>
) : null}
{steps.map((step) => (
<DebugStepCard key={step.id} step={step} defaultOpen={false} />
))}
{steps.length === 0 ? (
<p className="text-sm text-content-secondary">
No steps recorded.
</p>
) : null}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
};
@@ -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<DebugRunListProps> = ({
runs,
chatId,
isVisible,
}) => {
// Empty state is handled by DebugPanel before rendering this
// component. No guard here to avoid duplicated copy that drifts.
return (
<div className="w-full max-w-full min-w-0 space-y-3 p-4">
{runs.map((run) => (
<DebugRunCard
key={run.id}
run={run}
chatId={chatId}
isVisible={isVisible}
/>
))}
</div>
);
};
@@ -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<DebugStepCardProps> = ({
step,
defaultOpen = false,
}) => {
// Single active metadata pill: only one section open at a time.
const [activeSection, setActiveSection] = useState<SectionKey | null>(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 (
<Collapsible defaultOpen={defaultOpen}>
<div className="overflow-hidden rounded-lg border border-solid border-border-default/40 bg-surface-secondary/10">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-2 text-left transition-colors hover:bg-surface-secondary/25"
>
<div className="min-w-0 flex flex-1 items-center gap-2 overflow-hidden">
<span className="shrink-0 text-xs font-medium text-content-tertiary">
Step {step.step_number}
</span>
{model ? (
<span className="min-w-0 truncate text-xs text-content-secondary">
{model}
</span>
) : null}
<span className="shrink-0 whitespace-nowrap text-xs text-content-tertiary">
{durationLabel}
</span>
{tokenLabel ? (
<span className="shrink-0 whitespace-nowrap text-xs text-content-tertiary">
{tokenLabel}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge
size="xs"
variant={getStatusBadgeVariant(step.status)}
className="shrink-0"
>
{step.status || "unknown"}
</Badge>
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 text-content-secondary transition-transform",
"group-data-[state=open]:rotate-180",
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-0 border-t border-solid border-border-default/30 bg-surface-primary/10 px-3 pb-3 pt-3">
{/* ── Metadata bar ────────────────────────────── */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-content-secondary">
{model ? <MetadataItem label="Model" value={model} /> : null}
{request.options.max_output_tokens !== undefined ? (
<MetadataItem
label="Max tokens"
value={String(request.options.max_output_tokens)}
/>
) : null}
{request.policy.tool_choice !== undefined ? (
<MetadataItem
label="Tool choice"
value={(() => {
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 ? (
<span className="text-xs text-content-tertiary">
{attemptCount} {attemptCount === 1 ? "attempt" : "attempts"}
</span>
) : null}
</div>
{/* ── Pill toggles (single active) ───────────── */}
{hasPills ? (
<div className="flex flex-wrap gap-1">
{toolCount > 0 ? (
<PillToggle
label="Tools"
count={toolCount}
isActive={activeSection === "tools"}
onToggle={() => toggleSection("tools")}
icon={<WrenchIcon className="size-3" />}
/>
) : null}
{optionCount > 0 ? (
<PillToggle
label="Options"
count={optionCount}
isActive={activeSection === "options"}
onToggle={() => toggleSection("options")}
/>
) : null}
{usageEntryCount > 0 ? (
<PillToggle
label="Usage"
count={usageEntryCount}
isActive={activeSection === "usage"}
onToggle={() => toggleSection("usage")}
/>
) : null}
{policyCount > 0 ? (
<PillToggle
label="Policy"
count={policyCount}
isActive={activeSection === "policy"}
onToggle={() => toggleSection("policy")}
/>
) : null}
</div>
) : null}
{/* ── Active metadata section ────────────────── */}
{activeSection === "tools" && toolCount > 0 ? (
<div className="flex flex-col gap-1.5">
{request.tools.map((tool) => (
<div
key={tool.name}
className="rounded-md border border-solid border-border-default/40 bg-surface-secondary/10 p-2.5"
>
<ToolBadge label={tool.name} />
{tool.description ? (
<p className="mt-1 break-words text-2xs leading-4 text-content-secondary">
{tool.description}
</p>
) : null}
<ToolPayloadDisclosure
label="JSON schema"
code={tool.inputSchema}
copyLabel={`Copy ${tool.name} JSON schema`}
/>
</div>
))}
</div>
) : null}
{activeSection === "options" && optionCount > 0 ? (
<DebugDataSection title="Options">
<KeyValueGrid entries={request.options} />
</DebugDataSection>
) : null}
{activeSection === "usage" && usageEntryCount > 0 ? (
<DebugDataSection title="Usage">
<KeyValueGrid
entries={mergedUsage}
formatValue={(v) =>
typeof v === "number" ? v.toLocaleString("en-US") : String(v)
}
/>
</DebugDataSection>
) : null}
{activeSection === "policy" && policyCount > 0 ? (
<DebugDataSection title="Policy">
<KeyValueGrid entries={request.policy} />
</DebugDataSection>
) : null}
{/* ── Input / Output sections ──────────────────── */}
<div className="grid gap-4">
{/* ── Input column ────────────────────────── */}
<DebugDataSection title="Input">
{totalMessages > 0 ? (
<div className="space-y-2">
{hiddenCount > 0 ? (
<button
type="button"
onClick={() => setShowAllMessages(true)}
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
>
Show all {totalMessages} messages
</button>
) : null}
{showAllMessages &&
totalMessages > TRANSCRIPT_PREVIEW_COUNT ? (
<button
type="button"
onClick={() => setShowAllMessages(false)}
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
>
Show last {TRANSCRIPT_PREVIEW_COUNT} only
</button>
) : null}
{visibleMessages.map((msg, idx) => (
<MessageRow
key={hiddenCount + idx}
msg={msg}
clamp={!showAllMessages}
/>
))}
</div>
) : (
<EmptyHelper message="No input messages captured." />
)}
</DebugDataSection>
{/* ── Output column ───────────────────────── */}
<DebugDataSection title="Output">
{hasOutput ? (
<div className="space-y-2">
{/* Primary response content: visually prominent. */}
{response.content ? (
<p className="max-h-[28rem] overflow-auto whitespace-pre-wrap text-sm font-medium leading-6 text-content-primary">
{response.content}
</p>
) : null}
{/* Tool calls: structured cards with arguments. */}
{response.toolCalls.length > 0 ? (
<div className="space-y-1.5">
{response.toolCalls.map((tc, idx) => (
<ToolEventCard
key={tc.id ?? `${tc.name}-${idx}`}
badgeLabel={tc.name}
toolCallId={tc.id}
payloadLabel="Arguments"
payload={tc.arguments}
copyLabel={`Copy ${tc.name} arguments`}
/>
))}
</div>
) : null}
{/* Secondary metadata: finish reason + warnings. */}
{response.finishReason ? (
<span className="block text-2xs text-content-tertiary">
Finish: {response.finishReason}
</span>
) : null}
{response.warnings.length > 0 ? (
<div className="space-y-0.5">
{response.warnings.map((w, idx) => (
<p key={idx} className="text-xs text-content-warning">
<span aria-hidden="true">⚠</span>{" "}
<span className="sr-only">Warning: </span>
{w}
</p>
))}
</div>
) : null}
</div>
) : (
<EmptyHelper message="No output captured." />
)}
</DebugDataSection>
</div>
{/* ── Error ───────────────────────────────────── */}
{hasError ? (
<DebugDataSection title="Error">
<CopyableCodeBlock
code={errorText}
label={isStringError ? "Copy error text" : "Copy error JSON"}
/>
</DebugDataSection>
) : null}
{/* ── Request body JSON (lower priority) ─────── */}
<Collapsible>
<CollapsibleTrigger asChild>
<button
type="button"
className="group/raw flex items-center gap-1.5 border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronDownIcon className="size-3 transition-transform group-data-[state=open]/raw:rotate-180" />
Request body
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1.5">
<CopyableCodeBlock
code={safeJsonStringify(step.normalized_request)}
label="Copy request body JSON"
/>
</CollapsibleContent>
</Collapsible>
{/* ── Response body JSON ──────────────────────── */}
{step.normalized_response ? (
<Collapsible>
<CollapsibleTrigger asChild>
<button
type="button"
className="group/raw flex items-center gap-1.5 border-0 bg-transparent p-0 text-xs font-medium text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronDownIcon className="size-3 transition-transform group-data-[state=open]/raw:rotate-180" />
Response body
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1.5">
<CopyableCodeBlock
code={safeJsonStringify(step.normalized_response)}
label="Copy response body JSON"
/>
</CollapsibleContent>
</Collapsible>
) : null}
{/* ── Raw HTTP attempts ───────────────────────── */}
{attemptCount > 0 ||
(normalizedAttempts.rawFallback &&
normalizedAttempts.rawFallback !== "{}" &&
normalizedAttempts.rawFallback !== "[]") ? (
<DebugDataSection title="Raw attempts">
<DebugAttemptAccordion
attempts={normalizedAttempts.parsed}
rawFallback={normalizedAttempts.rawFallback}
/>
</DebugDataSection>
) : null}
</CollapsibleContent>
</div>
</Collapsible>
);
};
@@ -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<ToolPayloadDisclosureProps> = ({
label,
code,
copyLabel,
}) => {
if (!code) {
return null;
}
return (
<div className="mt-2 space-y-1">
<p className="text-2xs font-medium uppercase tracking-wide text-content-tertiary">
{label}
</p>
<CopyableCodeBlock code={code} label={copyLabel} className="max-h-56" />
</div>
);
};
export const ToolBadge: FC<{ label: string }> = ({ label }) => {
return (
<Badge size="sm" variant="purple" className="max-w-full">
<WrenchIcon className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</Badge>
);
};
interface ToolEventCardProps {
badgeLabel: string;
toolCallId?: string;
payloadLabel?: string;
payload?: string;
copyLabel?: string;
}
export const ToolEventCard: FC<ToolEventCardProps> = ({
badgeLabel,
toolCallId,
payloadLabel,
payload,
copyLabel,
}) => {
return (
<div className="rounded-md border border-solid border-border-default/40 bg-surface-secondary/10 p-2.5">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ToolBadge label={badgeLabel} />
{toolCallId ? (
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
{toolCallId}
</span>
) : null}
</div>
{payloadLabel && payload && copyLabel ? (
<ToolPayloadDisclosure
label={payloadLabel}
code={payload}
copyLabel={copyLabel}
/>
) : null}
</div>
);
};
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 (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<RoleBadge role={msg.role} />
</div>
<ToolEventCard
badgeLabel={badgeLabel}
toolCallId={msg.toolCallId}
payloadLabel={payloadLabel}
payload={payload}
copyLabel={`Copy ${badgeLabel} ${payloadLabel}`}
/>
</div>
);
};
const TranscriptTextRow: FC<MessageRowProps> = ({ 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 (
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<RoleBadge role={msg.role} />
{msg.toolName ? (
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
{msg.toolName}
</span>
) : null}
{msg.toolCallId && !msg.toolName ? (
<span className="min-w-0 truncate font-mono text-2xs text-content-tertiary">
{msg.toolCallId}
</span>
) : null}
</div>
{displayContent ? (
<>
<p
className={cn(
"whitespace-pre-wrap text-xs leading-5 text-content-primary",
showClamped && "line-clamp-3",
)}
>
{displayContent}
</p>
{needsClamp ? (
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="border-0 bg-transparent p-0 text-2xs font-medium text-content-link transition-colors hover:underline"
aria-label={`See ${expanded ? "less" : "more"} of ${msg.role} message`}
>
{expanded ? "see less" : "see more"}
</button>
) : null}
</>
) : null}
</div>
);
};
export const MessageRow: FC<MessageRowProps> = ({ msg, clamp }) => {
if (msg.kind === "tool-call" || msg.kind === "tool-result") {
return <TranscriptToolRow msg={msg} />;
}
return <TranscriptTextRow msg={msg} clamp={clamp} />;
};
@@ -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 });
});
});
@@ -24,7 +24,12 @@ const RUN_KIND_LABELS: Record<string, string> = {
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<readonly [string, ...string[]]> = [
["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<readonly [string, ...string[]]> = [
["tool_choice", "toolChoice"],
["response_format", "responseFormat"],
["structured_output", "structuredOutput"],
["parallel_tool_calls", "parallelToolCalls"],
];
const extractKnownFields = (
obj: Record<string, unknown>,
keys: readonly string[],
keys: ReadonlyArray<readonly [string, ...string[]]>,
): Record<string, unknown> => {
const result: Record<string, unknown> = {};
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;
};
// ---------------------------------------------------------------------------
@@ -36,7 +36,7 @@ const meta: Meta<typeof SidebarTabView> = {
component: SidebarTabView,
args: {
tabs: [gitTab],
activeTabId: "git",
effectiveTabId: "git",
onActiveTabChange: fn(),
isExpanded: false,
onToggleExpanded: fn(),
@@ -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<SidebarTabViewProps> = ({
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.
@@ -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");
});
});
@@ -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);
}