Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas Kosiewski a935d340b5 feat(site): add Debug panel components and settings
Change-Id: Ibeb0088ae26acd83e98d83d2eb00c0573962c357
Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-11 12:54:14 +02:00
Thomas Kosiewski af1b7a40ad feat(site): add chat debug API layer and panel utilities
Change-Id: Ic4573ae5d69c1b1a3ba2c32be5fa9ae9b078bea1
Signed-off-by: Thomas Kosiewski <tk@coder.com>
2026-04-11 12:54:13 +02:00
22 changed files with 2978 additions and 81 deletions
+5 -1
View File
@@ -7,7 +7,11 @@
"./test/**/*.ts",
"./e2e/**/*.ts"
],
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
"ignore": [
"**/*Generated.ts",
"src/api/chatModelOptions.ts",
"src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts"
],
"ignoreBinaries": ["protoc"],
"ignoreDependencies": [
"@babel/plugin-syntax-typescript",
+52
View File
@@ -3242,6 +3242,58 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatDebugLogging =
async (): Promise<TypesGen.ChatDebugLoggingAdminSettings> => {
const response =
await this.axios.get<TypesGen.ChatDebugLoggingAdminSettings>(
"/api/experimental/chats/config/debug-logging",
);
return response.data;
};
updateChatDebugLogging = async (
req: TypesGen.UpdateChatDebugLoggingAllowUsersRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/debug-logging", req);
};
getUserChatDebugLogging =
async (): Promise<TypesGen.UserChatDebugLoggingSettings> => {
const response =
await this.axios.get<TypesGen.UserChatDebugLoggingSettings>(
"/api/experimental/chats/config/user-debug-logging",
);
return response.data;
};
updateUserChatDebugLogging = async (
req: TypesGen.UpdateUserChatDebugLoggingRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/user-debug-logging",
req,
);
};
getChatDebugRuns = async (
chatId: string,
): Promise<TypesGen.ChatDebugRunSummary[]> => {
const response = await this.axios.get<TypesGen.ChatDebugRunSummary[]>(
`/api/experimental/chats/${chatId}/debug/runs`,
);
return response.data;
};
getChatDebugRun = async (
chatId: string,
runId: string,
): Promise<TypesGen.ChatDebugRun> => {
const response = await this.axios.get<TypesGen.ChatDebugRun>(
`/api/experimental/chats/${chatId}/debug/runs/${runId}`,
);
return response.data;
};
getChatDesktopEnabled =
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
const response =
+29 -13
View File
@@ -679,6 +679,8 @@ describe("mutation invalidation scope", () => {
queryClient.setQueryData(chatKey(chatId), makeChat(chatId));
// Messages: ["chats", chatId, "messages"]
queryClient.setQueryData(chatMessagesKey(chatId), []);
// Debug runs: ["chats", chatId, "debug-runs"]
queryClient.setQueryData(chatDebugRunsTestKey(chatId), []);
// Diff contents: ["chats", chatId, "diff-contents"]
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
// Cost summary: ["chats", "costSummary", "me", undefined]
@@ -688,6 +690,9 @@ describe("mutation invalidation scope", () => {
);
};
const chatDebugRunsTestKey = (chatId: string) =>
["chats", chatId, "debug-runs"] as const;
/** Keys that should NEVER be invalidated by chat message mutations
* because they are completely unrelated to the message flow. */
const unrelatedKeys = (chatId: string) => [
@@ -700,13 +705,9 @@ describe("mutation invalidation scope", () => {
const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId);
// createChatMessage has no onSuccess handler — the WebSocket
// stream covers all real-time updates. Verify that constructing
// the mutation config does not define one.
const mutation = createChatMessage(queryClient, chatId);
expect(mutation).not.toHaveProperty("onSuccess");
await mutation.onSuccess?.();
// Since there is no onSuccess, no queries should be invalidated.
for (const { label, key } of unrelatedKeys(chatId)) {
const state = queryClient.getQueryState(key);
expect(
@@ -716,14 +717,18 @@ describe("mutation invalidation scope", () => {
}
});
it("createChatMessage does not invalidate chat detail or messages (WebSocket handles these)", async () => {
it("createChatMessage invalidates only debug runs, not chat detail or messages", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId);
// No onSuccess handler exists.
const mutation = createChatMessage(queryClient, chatId);
expect(mutation).not.toHaveProperty("onSuccess");
await mutation.onSuccess?.();
expect(
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
const chatState = queryClient.getQueryState(chatKey(chatId));
expect(
@@ -757,7 +762,7 @@ describe("mutation invalidation scope", () => {
}
});
it("editChatMessage invalidates only chat detail and messages", async () => {
it("editChatMessage invalidates chat detail, messages, and debug runs", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId);
@@ -767,8 +772,9 @@ describe("mutation invalidation scope", () => {
await new Promise((r) => setTimeout(r, 0));
// These two should still be invalidated — editing changes
// message content and potentially the chat's updated_at.
// These queries should be invalidated — editing changes
// message content, may update the chat record, and can start
// a new debug run.
const chatState = queryClient.getQueryState(chatKey(chatId));
expect(chatState?.isInvalidated, "chatKey should be invalidated").toBe(
true,
@@ -779,6 +785,11 @@ describe("mutation invalidation scope", () => {
messagesState?.isInvalidated,
"chatMessagesKey should be invalidated",
).toBe(true);
expect(
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
});
// Shared type for the infinite messages cache shape used by
@@ -1131,13 +1142,18 @@ describe("mutation invalidation scope", () => {
}
});
it("promoteChatQueuedMessage does not invalidate unrelated queries", async () => {
it("promoteChatQueuedMessage invalidates debug runs without touching unrelated queries", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId);
const mutation = promoteChatQueuedMessage(queryClient, chatId);
expect(mutation).not.toHaveProperty("onSuccess");
await mutation.onSuccess?.();
expect(
queryClient.getQueryState(chatDebugRunsTestKey(chatId))?.isInvalidated,
"chatDebugRunsKey should be invalidated",
).toBe(true);
for (const { label, key } of unrelatedKeys(chatId)) {
const state = queryClient.getQueryState(key);
+57 -8
View File
@@ -581,6 +581,15 @@ export const regenerateChatTitle = (queryClient: QueryClient) => ({
},
});
const chatDebugRunsKey = (chatId: string) =>
["chats", chatId, "debug-runs"] as const;
const invalidateChatDebugRuns = (queryClient: QueryClient, chatId: string) => {
return queryClient.invalidateQueries({
queryKey: chatDebugRunsKey(chatId),
});
};
export const createChat = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatRequest) =>
API.experimental.createChat(req),
@@ -593,14 +602,17 @@ export const createChat = (queryClient: QueryClient) => ({
});
export const createChatMessage = (
_queryClient: QueryClient,
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (req: TypesGen.CreateChatMessageRequest) =>
API.experimental.createChatMessage(chatId, req),
// No onSuccess invalidation needed: the per-chat WebSocket delivers
// the response message via upsertDurableMessage, and the global
// watchChats() WebSocket updates the sidebar sort order.
onSuccess: async () => {
await invalidateChatDebugRuns(queryClient, chatId);
},
// The per-chat and sidebar WebSockets cover message/status updates,
// but the Debug panel uses polling. Kick its list query immediately
// so newly-started runs appear without tab switching.
});
type EditChatMessageMutationArgs = {
@@ -684,6 +696,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
queryKey: chatMessagesKey(chatId),
exact: true,
});
void invalidateChatDebugRuns(queryClient, chatId);
},
});
@@ -713,14 +726,16 @@ export const deleteChatQueuedMessage = (
});
export const promoteChatQueuedMessage = (
_queryClient: QueryClient,
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (queuedMessageId: number) =>
API.experimental.promoteChatQueuedMessage(chatId, queuedMessageId),
// No onSuccess invalidation needed: the caller upserts the
// promoted message from the response, and the per-chat
// WebSocket delivers queue and status updates in real-time.
onSuccess: async () => {
await invalidateChatDebugRuns(queryClient, chatId);
},
// The caller still upserts the promoted message directly, but the
// Debug panel needs an explicit refresh to discover the new run.
});
export const chatDiffContentsKey = (chatId: string) =>
@@ -764,6 +779,40 @@ export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
},
});
const chatDebugLoggingKey = ["chat-debug-logging"] as const;
const userChatDebugLoggingKey = ["user-chat-debug-logging"] as const;
export const chatDebugLogging = () => ({
queryKey: chatDebugLoggingKey,
queryFn: () => API.experimental.getChatDebugLogging(),
});
export const userChatDebugLogging = () => ({
queryKey: userChatDebugLoggingKey,
queryFn: () => API.experimental.getUserChatDebugLogging(),
});
export const updateChatDebugLogging = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatDebugLogging,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatDebugLoggingKey,
});
await queryClient.invalidateQueries({
queryKey: userChatDebugLoggingKey,
});
},
});
export const updateUserChatDebugLogging = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateUserChatDebugLogging,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: userChatDebugLoggingKey,
});
},
});
const chatWorkspaceTTLKey = ["chat-workspace-ttl"] as const;
export const chatWorkspaceTTL = () => ({
@@ -130,6 +130,7 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
diffStatusData: undefined as ComponentProps<
typeof AgentChatPageView
>["diffStatusData"],
debugLoggingEnabled: false,
gitWatcher: buildGitWatcher(),
canOpenEditors: false,
canOpenWorkspace: false,
+63 -41
View File
@@ -41,7 +41,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 { TerminalPanel } from "./components/TerminalPanel";
import type { ChatDetailError } from "./utils/usageLimitMessage";
@@ -267,6 +269,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onOpenDesktop: desktopChatId ? handleOpenDesktop : undefined,
};
const shouldShowSidebar = showSidebarPanel;
// Compute local diff stats from git watcher unified diffs.
const workspaceRoute = workspace
@@ -300,14 +304,70 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
};
})();
const sidebarTabIds = [
"git",
...(workspace && workspaceAgent ? ["terminal"] : []),
"debug",
];
const effectiveSidebarTabId = getEffectiveTabId(
sidebarTabIds,
sidebarTabId,
desktopChatId,
);
const sidebarTabs = [
{
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 && effectiveSidebarTabId === "terminal"
}
workspace={workspace}
workspaceAgent={workspaceAgent}
/>
),
},
]
: []),
{
id: "debug",
label: "Debug",
content: (
<DebugPanel
chatId={agentId}
enabled={shouldShowSidebar && effectiveSidebarTabId === "debug"}
/>
),
},
];
const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
</title>
);
const shouldShowSidebar = showSidebarPanel;
return (
<DesktopPanelContext value={desktopPanelCtx}>
<div
@@ -445,45 +505,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
<SidebarTabView
activeTabId={sidebarTabId}
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)}
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDebugLogging,
chatDesktopEnabled,
chatModelConfigs,
chatRetentionDays,
@@ -8,12 +9,15 @@ import {
chatUserCustomPrompt,
chatWorkspaceTTL,
deleteUserCompactionThreshold,
updateChatDebugLogging,
updateChatDesktopEnabled,
updateChatRetentionDays,
updateChatSystemPrompt,
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
updateUserChatDebugLogging,
updateUserCompactionThreshold,
userChatDebugLogging,
userCompactionThresholds,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
@@ -41,6 +45,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),
@@ -79,6 +96,8 @@ const AgentSettingsBehaviorPage: FC = () => {
systemPromptData={systemPromptQuery.data}
userPromptData={userPromptQuery.data}
desktopEnabledData={desktopEnabledQuery.data}
debugLoggingData={debugLoggingQuery.data}
userDebugLoggingData={userDebugLoggingQuery.data}
workspaceTTLData={workspaceTTLQuery.data}
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
@@ -99,6 +118,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,5 +1,6 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { DebugLoggingSettings } from "./components/DebugLoggingSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings";
import { SectionHeader } from "./components/SectionHeader";
@@ -20,6 +21,8 @@ interface AgentSettingsBehaviorPageViewProps {
systemPromptData: TypesGen.ChatSystemPromptResponse | 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;
@@ -62,6 +65,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,
@@ -84,6 +101,8 @@ export const AgentSettingsBehaviorPageView: FC<
systemPromptData,
userPromptData,
desktopEnabledData,
debugLoggingData,
userDebugLoggingData,
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
@@ -107,6 +126,12 @@ export const AgentSettingsBehaviorPageView: FC<
onSaveDesktopEnabled,
isSavingDesktopEnabled,
isSaveDesktopEnabledError,
onSaveDebugLogging,
isSavingDebugLogging,
isSaveDebugLoggingError,
onSaveUserDebugLogging,
isSavingUserDebugLogging,
isSaveUserDebugLoggingError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
@@ -120,7 +145,7 @@ export const AgentSettingsBehaviorPageView: FC<
<>
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your conversations."
description="Custom instructions and debug controls that shape how the agent responds in your conversations."
/>
<PersonalInstructionsSettings
@@ -131,6 +156,19 @@ export const AgentSettingsBehaviorPageView: FC<
isAnyPromptSaving={isAnyPromptSaving}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<DebugLoggingSettings
canManageAdminSetting={canSetSystemPrompt}
adminSettings={debugLoggingData}
userSettings={userDebugLoggingData}
onSaveAdminSetting={onSaveDebugLogging}
isSavingAdminSetting={isSavingDebugLogging}
isSaveAdminSettingError={isSaveDebugLoggingError}
onSaveUserSetting={onSaveUserDebugLogging}
isSavingUserSetting={isSavingUserDebugLogging}
isSaveUserSettingError={isSaveUserDebugLoggingError}
/>
<hr className="my-5 border-0 border-t border-solid border-border" />
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
@@ -3,6 +3,7 @@ import { type InfiniteData, useQueryClient } from "react-query";
import { watchChat } from "#/api/api";
import { chatMessagesKey, updateInfiniteChatsCache } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
@@ -0,0 +1,135 @@
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">
Allow User 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">
Deployment configuration already forces chat debug logging on
for every chat. This runtime user opt-in setting is currently
ignored.
</p>
) : (
<p className="m-0">
Allow users to opt into normalized model state and raw
provider request/response logging from their personal Behavior
settings.
</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">
Personal Chat Debug Logs
</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">
Deployment configuration forces chat debug logging on for every
chat. Your personal toggle is read-only while this is enabled.
</p>
) : userToggleAllowed ? (
<p className="m-0">
Capture normalized model state and raw provider request/response
payloads for your own chats.
</p>
) : (
<p className="m-0">
An administrator has not enabled user-controlled chat debug
logging yet.
</p>
)}
</div>
<Switch
checked={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,187 @@
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 {
DEBUG_PANEL_METADATA_CLASS_NAME,
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={DEBUG_PANEL_METADATA_CLASS_NAME}>
<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,85 @@
import type { FC, ReactNode } from "react";
import { useQuery } from "react-query";
import { getErrorMessage } from "#/api/errors";
import { Alert } from "#/components/Alert/Alert";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Spinner } from "#/components/Spinner/Spinner";
import { DebugRunList } from "./DebugRunList";
import { chatDebugRuns } from "./debugQueries";
interface DebugPanelProps {
chatId: string;
enabled?: boolean;
}
export const DebugPanel: FC<DebugPanelProps> = ({
chatId,
enabled = false,
}) => {
const runsQuery = useQuery({
...chatDebugRuns(chatId),
enabled,
});
const sortedRuns = [...(runsQuery.data ?? [])].sort((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;
});
let content: ReactNode;
if (runsQuery.isError) {
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 = (
<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>
Enable it from <strong>Settings Behavior</strong> if your admin
allows user-controlled debug logging, or ask an admin to turn it on
globally.
</p>
</div>
);
} else {
content = (
<DebugRunList runs={sortedRuns} chatId={chatId} enabled={enabled} />
);
}
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,198 @@
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 } from "./debugPanelUtils";
const DEBUG_PANEL_SECTION_TITLE_CLASS_NAME =
"text-xs font-medium text-content-secondary";
export const DEBUG_PANEL_METADATA_CLASS_NAME =
"flex flex-wrap gap-x-3 gap-y-1 text-xs leading-5 text-content-secondary";
const DEBUG_PANEL_SECTION_CLASS_NAME = "space-y-1.5";
const DEBUG_PANEL_CODE_BLOCK_CLASS_NAME =
"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";
interface DebugDataSectionProps {
title: string;
description?: ReactNode;
children: ReactNode;
className?: string;
}
export const DebugDataSection: FC<DebugDataSectionProps> = ({
title,
description,
children,
className,
}) => {
return (
<section className={cn(DEBUG_PANEL_SECTION_CLASS_NAME, className)}>
<h4 className={DEBUG_PANEL_SECTION_TITLE_CLASS_NAME}>{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(DEBUG_PANEL_CODE_BLOCK_CLASS_NAME, 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?: React.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 ? JSON.stringify(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,155 @@
import { ChevronDownIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { getErrorMessage } from "#/api/errors";
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";
import { chatDebugRun } from "./debugQueries";
interface DebugRunCardProps {
run: ChatDebugRunSummary;
chatId: string;
enabled?: boolean;
}
const getDurationLabel = (startedAt: string, finishedAt?: string): string => {
const durationMs = computeDurationMs(startedAt, finishedAt);
return durationMs !== null ? compactDuration(durationMs) : "—";
};
export const DebugRunCard: FC<DebugRunCardProps> = ({
run,
chatId,
enabled = true,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const runDetailQuery = useQuery({
...chatDebugRun(chatId, run.id),
enabled: enabled && 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),
80,
);
// 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 is string => item !== undefined);
const running = isActiveStatus(run.status);
return (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 border-0 bg-transparent px-3 py-1.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(run.status)}
className="shrink-0"
>
{run.status || "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-4 pb-4 pt-2">
{runDetailQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Spinner size="sm" loading />
Loading run details...
</div>
) : runDetailQuery.isError ? (
<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">
{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;
enabled?: boolean;
}
export const DebugRunList: FC<DebugRunListProps> = ({
runs,
chatId,
enabled = true,
}) => {
// 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">
{runs.map((run) => (
<DebugRunCard
key={run.id}
run={run}
chatId={chatId}
enabled={enabled}
/>
))}
</div>
);
};
@@ -0,0 +1,446 @@
import { ChevronDownIcon, WrenchIcon } from "lucide-react";
import { type FC, useState } from "react";
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,
DEBUG_PANEL_METADATA_CLASS_NAME,
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.
const stringError =
typeof step.error === "string" ? (step.error as string) : undefined;
const hasError =
(stringError !== undefined && stringError.trim().length > 0) ||
(!!step.error &&
typeof step.error === "object" &&
Object.keys(step.error).length > 0);
const errorCode = stringError ?? safeJsonStringify(step.error);
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-t border-solid border-border-default/30 bg-surface-primary/10 px-3 pb-3 pt-3">
{/* ── Metadata bar ────────────────────────────── */}
<div className={DEBUG_PANEL_METADATA_CLASS_NAME}>
{model ? <MetadataItem label="Model" value={model} /> : null}
{request.options.max_output_tokens !== undefined ||
request.options.maxOutputTokens !== undefined ||
request.options.max_tokens !== undefined ||
request.options.maxTokens !== undefined ? (
<MetadataItem
label="Max tokens"
value={String(
request.options.max_output_tokens ??
request.options.maxOutputTokens ??
request.options.max_tokens ??
request.options.maxTokens,
)}
/>
) : null}
{request.policy.tool_choice !== undefined ||
request.policy.toolChoice !== undefined ? (
<MetadataItem
label="Tool choice"
value={(() => {
const tc =
request.policy.tool_choice ?? request.policy.toolChoice;
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={errorCode}
label={
stringError !== undefined
? "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,164 @@
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,
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);
const needsClamp = clamp && msg.content.length > 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} />;
};
@@ -0,0 +1,25 @@
import { coerceStepResponse } from "./debugPanelUtils";
describe("coerceStepResponse", () => {
it("keeps tool-result content emitted in normalized response parts", () => {
const response = coerceStepResponse({
content: [
{
type: "tool-result",
tool_call_id: "call-1",
tool_name: "search_docs",
result: {
matches: ["model.go", "debugPanelUtils.ts"],
},
},
],
});
const parsed = JSON.parse(response.content);
expect(parsed).toEqual({
matches: ["model.go", "debugPanelUtils.ts"],
});
expect(response.toolCalls).toEqual([]);
expect(response.usage).toEqual({});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
// Debug-specific query factories live here rather than in the
// shared site/src/api/queries/chats.ts to keep the main chat
// queries module focused on core chat operations.
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
// ---------------------------------------------------------------------------
// Terminal status detection (shared by list and detail queries).
// ---------------------------------------------------------------------------
const debugRunTerminalStatuses = new Set(["completed", "error", "interrupted"]);
const debugRunRefetchInterval = (
run: Pick<TypesGen.ChatDebugRun, "status"> | undefined,
hasError?: boolean,
): number | false => {
if (hasError) {
return false;
}
if (run?.status && debugRunTerminalStatuses.has(run.status.toLowerCase())) {
return false;
}
return 5_000;
};
// ---------------------------------------------------------------------------
// Query factories.
// ---------------------------------------------------------------------------
const chatDebugRunsKey = (chatId: string) =>
["chats", chatId, "debug-runs"] as const;
export const chatDebugRuns = (chatId: string) => ({
queryKey: chatDebugRunsKey(chatId),
queryFn: () => API.experimental.getChatDebugRuns(chatId),
refetchInterval: ({
state,
}: {
state: {
data?: TypesGen.ChatDebugRunSummary[] | undefined;
status: string;
};
}): number | false => {
if (state.status === "error") {
return false;
}
// Keep polling at a consistent foreground cadence while the
// Debug tab is open. A slower terminal-state interval delays
// discovery of newly-started runs until the user switches tabs.
return 5_000;
},
refetchIntervalInBackground: false,
});
export const chatDebugRun = (chatId: string, runId: string) => ({
queryKey: [...chatDebugRunsKey(chatId), runId] as const,
queryFn: () => API.experimental.getChatDebugRun(chatId, runId),
refetchInterval: ({
state,
}: {
state: { data: TypesGen.ChatDebugRun | undefined; status: string };
}) => debugRunRefetchInterval(state.data, state.status === "error"),
refetchIntervalInBackground: false,
});
@@ -11,6 +11,7 @@ import { type FC, useEffect, useId, useRef, useState } from "react";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { DesktopPanel } from "../RightPanel/DesktopPanel";
import { getEffectiveTabId } from "./getEffectiveTabId";
/** A single tab definition for the sidebar panel. */
export interface SidebarTab {
@@ -115,23 +116,8 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
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;
const tabIds = tabs.map((t) => t.id);
const effectiveTabId = getEffectiveTabId(tabIds, activeTabId, desktopChatId);
// Unified list of panels for rendering. Includes the desktop
// tab when available so we don't need to special-case it.
@@ -0,0 +1,33 @@
/**
* 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).
*
* This function is shared between AgentChatPageView (which needs
* the effective tab before constructing tab content) and
* SidebarTabView (which needs it to drive CSS visibility and the
* active indicator). Keeping one implementation prevents the
* fallback logic from drifting between the two call sites.
*/
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);
}