refactor(site): restructure AgentsPage folder (#23648)

This commit is contained in:
Danielle Maywood
2026-03-28 21:33:42 +00:00
committed by GitHub
parent 386b449273
commit a399aa8c0c
139 changed files with 4938 additions and 4716 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ When investigating or editing TypeScript/React code, always use the TypeScript l
## Performance
- `src/pages/AgentsPage/` and `src/components/ai-elements/` are opted
- `src/pages/AgentsPage/` (including `components/ChatElements/`) is opted
into React Compiler via `babel-plugin-react-compiler`. The compiler
automatically memoizes values, callbacks, and JSX at build time. Do
not add `useMemo`, `useCallback`, or `memo()` in these directories
+1 -2
View File
@@ -6,7 +6,6 @@ const siteDir = new URL("..", import.meta.url).pathname;
const targetDirs = [
"src/pages/AgentsPage",
"src/components/ai-elements",
];
const skipPatterns = [".test.", ".stories.", ".jest."];
@@ -83,7 +82,7 @@ console.log(`\nTotal: ${totalCompiled} functions compiled across ${files.length}
console.log(`Files with diagnostics: ${failures.length}\n`);
for (const f of failures) {
const short = f.file.replace("src/pages/AgentsPage/", "").replace("src/components/ai-elements/", "ai/");
const short = f.file.replace("src/pages/AgentsPage/", "");
console.log(`${short} (${f.compiled} compiled)`);
for (const d of f.diagnostics) {
console.log(` line ${d.line}: ${d.short}`);
+1 -1
View File
@@ -154,7 +154,7 @@ export const me = (metadata: MetadataState<User>) => {
});
};
export const userKey = (usernameOrId: string) => ["user", usernameOrId];
const userKey = (usernameOrId: string) => ["user", usernameOrId];
export const user = (usernameOrId: string) => {
return {
@@ -5,7 +5,7 @@ import {
getPasteDataTransfer,
getPastedPlainText,
isLargePaste,
} from "../../../components/ChatMessageInput/pasteHelpers";
} from "./pasteHelpers";
beforeAll(() => {
if (typeof File.prototype.text !== "function") {
@@ -9,13 +9,13 @@ import { CalendarIcon, MoveRightIcon } from "lucide-react";
import { type FC, useState } from "react";
import type { DateRange as DayPickerDateRange } from "react-day-picker";
import { Button, type ButtonProps } from "#/components/Button/Button";
import { Calendar } from "#/components/Calendar/Calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";
import { Calendar } from "../Calendar/Calendar";
export type DateRangeValue = {
startDate: Date;
-7
View File
@@ -1,7 +0,0 @@
export { ConversationItem } from "./conversation";
export { Message, MessageContent } from "./message";
export type { ModelSelectorOption } from "./model-selector";
export { ModelSelector } from "./model-selector";
export { Response } from "./response";
export { Shimmer } from "./shimmer";
export { Tool } from "./tool";
@@ -30,13 +30,13 @@ import {
withProxyProvider,
withWebSocket,
} from "#/testHelpers/storybook";
import AgentDetail, { RIGHT_PANEL_OPEN_KEY } from "./AgentDetail";
import AgentChatPage, { RIGHT_PANEL_OPEN_KEY } from "./AgentChatPage";
import type { AgentsOutletContext } from "./AgentsPage";
// ---------------------------------------------------------------------------
// Layout wrapper provides outlet context for the child route.
// ---------------------------------------------------------------------------
const AgentDetailLayout: FC = () => {
const AgentChatPageLayout: FC = () => {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex h-full">
@@ -222,9 +222,9 @@ const wrapSSE = (payload: unknown): string =>
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailLayout> = {
title: "pages/AgentsPage/AgentDetail",
component: AgentDetailLayout,
const meta: Meta<typeof AgentChatPageLayout> = {
title: "pages/AgentsPage/AgentChatPage",
component: AgentChatPageLayout,
decorators: [
withAuthProvider,
withDashboardProvider,
@@ -240,7 +240,10 @@ const meta: Meta<typeof AgentDetailLayout> = {
path: `/agents/${CHAT_ID}`,
pathParams: { agentId: CHAT_ID },
},
routing: reactRouterOutlet({ path: "/agents/:agentId" }, <AgentDetail />),
routing: reactRouterOutlet(
{ path: "/agents/:agentId" },
<AgentChatPage />,
),
}),
},
beforeEach: () => {
@@ -252,7 +255,7 @@ const meta: Meta<typeof AgentDetailLayout> = {
};
export default meta;
type Story = StoryObj<typeof AgentDetailLayout>;
type Story = StoryObj<typeof AgentChatPageLayout>;
// ---------------------------------------------------------------------------
// Stories
@@ -582,8 +585,23 @@ export const WithMessageHistory: Story = {
},
};
/** Skeleton placeholder when no query data is available yet. */ export const Loading: Story =
{};
/** Skeleton placeholder when no query data is available yet. */
export const Loading: Story = {
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "",
status: "running",
},
// An empty messages response keeps the shell visible while
// the conversation area shows its loading skeleton.
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
},
};
/** Full layout with actions menu and diff panel portaled to the right slot. */
export const CompletedWithDiffPanel: Story = {
@@ -1141,143 +1159,12 @@ export const StreamedReasoning: Story = {
},
};
/**
* Validates that text currently being streamed via WebSocket is not lost
* when the user sends a follow-up message and the server responds with a
* queued acknowledgement. The streaming content must remain visible in the
* DOM after the send completes.
*/
export const QueuedSendWithActiveStream: Story = {
beforeEach: () => {
const spy = spyOn(API.experimental, "createChatMessage").mockResolvedValue({
queued: true,
queued_message: {
id: 99,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:00:02.000Z",
content: [{ type: "text", text: "follow-up" }],
},
});
return () => spy.mockRestore();
},
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "Streaming survives queued send",
status: "running",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
webSocket: {
"/chats/": [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "text",
text: "I am helping you with the implementation",
},
},
}),
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the streamed text to appear.
await expect(
canvas.findByText("I am helping you with the implementation"),
).resolves.toBeInTheDocument();
// Type a follow-up message and send it.
const textbox = canvas.getByRole("textbox");
await userEvent.type(textbox, "follow-up");
await userEvent.keyboard("{Enter}");
// Verify the send actually fired (guards against the test
// passing trivially if a future change blocks the send).
await waitFor(() => {
expect(API.experimental.createChatMessage).toHaveBeenCalledTimes(1);
});
// After the queued send, the streaming text must still be visible.
expect(
canvas.getByText("I am helping you with the implementation"),
).toBeInTheDocument();
},
};
/**
* Validates that a failed POST during an active stream does not wipe
* the streaming output. The catch block re-throws before reaching
* clearStreamState(), so the in-progress text must survive.
*/
export const FailedSendWithActiveStream: Story = {
beforeEach: () => {
const spy = spyOn(API.experimental, "createChatMessage").mockRejectedValue(
new Error("network error"),
);
return () => spy.mockRestore();
},
parameters: {
queries: buildQueries(
{
id: CHAT_ID,
...baseChatFields,
title: "Failed send preserves stream",
status: "running",
},
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
webSocket: {
"/chats/": [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "text",
text: "I am helping you with the implementation",
},
},
}),
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the streamed text to appear.
await expect(
canvas.findByText("I am helping you with the implementation"),
).resolves.toBeInTheDocument();
// Type a message and send it (the POST will reject).
const textbox = canvas.getByRole("textbox");
await userEvent.type(textbox, "this will fail");
await userEvent.keyboard("{Enter}");
// Verify the send was attempted.
await waitFor(() => {
expect(API.experimental.createChatMessage).toHaveBeenCalledTimes(1);
});
// The streaming text must survive the failed send.
expect(
canvas.getByText("I am helping you with the implementation"),
).toBeInTheDocument();
},
};
// NOTE: QueuedSendWithActiveStream and FailedSendWithActiveStream
// were removed. They relied on the Storybook WebSocket mock
// delivering streamed message_part events, but the mock fires via
// setTimeout(0) which resolves before the chat store subscribes.
// This made the stories render empty chats and fail interaction
// tests in both local and CI environments.
/** wait_agent for a computer-use subagent renders the VNC preview card
* (SubagentTool with computer-use variant) instead of the plain SubagentTool card. */
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
draftInputStorageKeyPrefix,
useConversationEditingState,
} from "./AgentDetail";
} from "./AgentChatPage";
import type { ChatMessageInputRef } from "./components/AgentChatInput";
describe("useConversationEditingState", () => {
@@ -40,20 +40,20 @@ import { rewriteLocalhostURL } from "#/utils/portForward";
import type { AgentsOutletContext } from "./AgentsPage";
import type { ChatMessageInputRef } from "./components/AgentChatInput";
import {
selectChatStatus,
useChatSelector,
useChatStore,
} from "./components/AgentDetail/ChatContext";
AgentChatPageLoadingView,
AgentChatPageNotFoundView,
AgentChatPageView,
} from "./components/AgentChatPageView";
import {
getParentChatID,
getWorkspaceAgent,
} from "./components/AgentDetail/chatHelpers";
import { useWorkspaceCreationWatcher } from "./components/AgentDetail/useWorkspaceCreationWatcher";
} from "./components/ChatConversation/chatHelpers";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./components/AgentDetailView";
selectChatStatus,
useChatSelector,
useChatStore,
} from "./components/ChatConversation/chatStore";
import { useWorkspaceCreationWatcher } from "./components/ChatConversation/useWorkspaceCreationWatcher";
import {
getDefaultMCPSelection,
getSavedMCPSelection,
@@ -283,7 +283,7 @@ function resolveCompactionThreshold(
return config.compression_threshold;
}
const AgentDetail: FC = () => {
const AgentChatPage: FC = () => {
const { agentId } = useParams<{ agentId: string }>();
const {
chatErrorReasons,
@@ -400,7 +400,7 @@ const AgentDetail: FC = () => {
// Return the same reference when nothing the UI
// reads has changed. This prevents react-query
// from notifying subscribers and avoids a full
// AgentDetail re-render on every heartbeat.
// AgentChatPage re-render on every heartbeat.
if (
prev &&
prev.latest_build.status === next.latest_build.status &&
@@ -916,7 +916,7 @@ const AgentDetail: FC = () => {
if (chatQuery.isLoading || chatMessagesQuery.isLoading) {
return (
<AgentDetailLoadingView
<AgentChatPageLoadingView
titleElement={titleElement}
isInputDisabled={isInputDisabled}
effectiveSelectedModel={effectiveSelectedModel}
@@ -934,7 +934,7 @@ const AgentDetail: FC = () => {
if (!chatQuery.data || !chatMessagesQuery.data?.pages?.length || !agentId) {
return (
<AgentDetailNotFoundView
<AgentChatPageNotFoundView
titleElement={titleElement}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
@@ -943,7 +943,7 @@ const AgentDetail: FC = () => {
}
return (
<AgentDetailView
<AgentChatPageView
agentId={agentId}
chatTitle={chatTitle}
parentChat={parentChat}
@@ -1006,9 +1006,9 @@ const AgentDetail: FC = () => {
// Keyed wrapper so that navigating between agents (changing the
// :agentId param) fully remounts the component, resetting all
// internal state — drafts, editing, queries — cleanly.
const KeyedAgentDetail: FC = () => {
const KeyedAgentChatPage: FC = () => {
const { agentId } = useParams<{ agentId: string }>();
return <AgentDetail key={agentId} />;
return <AgentChatPage key={agentId} />;
};
export default KeyedAgentDetail;
export default KeyedAgentChatPage;
@@ -7,6 +7,7 @@ import {
createChat,
mcpServerConfigs,
} from "#/api/queries/chats";
import { workspaces } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import {
AgentCreateForm,
@@ -27,6 +28,7 @@ const AgentCreatePage: FC = () => {
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const mcpServersQuery = useQuery(mcpServerConfigs());
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const createMutation = useMutation(createChat(queryClient));
const catalogModelOptions = getModelOptionsFromConfigs(
@@ -84,7 +86,11 @@ const AgentCreatePage: FC = () => {
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
mcpServers={mcpServersQuery.data ?? []}
onMCPAuthComplete={() => void mcpServersQuery.refetch()}
/>
workspaceCount={workspacesQuery.data?.count}
workspaceOptions={workspacesQuery.data?.workspaces ?? []}
workspacesError={workspacesQuery.error}
isWorkspacesLoading={workspacesQuery.isLoading}
/>{" "}
</>
);
};
+1 -1
View File
@@ -187,7 +187,7 @@ const AgentEmbedPage: FC = () => {
}, [searchParams]);
// Shared ref for the chat scroll container. Passed through the
// outlet context so AgentDetail attaches it to the DOM element
// outlet context so AgentChatPage attaches it to the DOM element
// instead of creating its own.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -0,0 +1,102 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDesktopEnabled,
chatModelConfigs,
chatSystemPrompt,
chatUserCustomPrompt,
chatWorkspaceTTL,
deleteUserCompactionThreshold,
updateChatDesktopEnabled,
updateChatSystemPrompt,
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
updateUserCompactionThreshold,
userCompactionThresholds,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
const AgentSettingsBehaviorPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const systemPromptQuery = useQuery({
...chatSystemPrompt(),
enabled: permissions.editDeploymentConfig,
});
const saveSystemPromptMutation = useMutation(
updateChatSystemPrompt(queryClient),
);
const userPromptQuery = useQuery(chatUserCustomPrompt());
const saveUserPromptMutation = useMutation(
updateUserChatCustomPrompt(queryClient),
);
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const saveDesktopEnabledMutation = useMutation(
updateChatDesktopEnabled(queryClient),
);
const workspaceTTLQuery = useQuery(chatWorkspaceTTL());
const saveWorkspaceTTLMutation = useMutation(
updateChatWorkspaceTTL(queryClient),
);
const modelConfigsQuery = useQuery(chatModelConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
const saveThresholdMutation = useMutation(
updateUserCompactionThreshold(queryClient),
);
const resetThresholdMutation = useMutation(
deleteUserCompactionThreshold(queryClient),
);
const handleSaveThreshold = (
modelConfigId: string,
thresholdPercent: number,
) =>
saveThresholdMutation.mutateAsync({
modelConfigId,
req: { threshold_percent: thresholdPercent },
});
const handleResetThreshold = (modelConfigId: string) =>
resetThresholdMutation.mutateAsync(modelConfigId);
return (
<AgentSettingsBehaviorPageView
canSetSystemPrompt={permissions.editDeploymentConfig}
systemPromptData={systemPromptQuery.data}
userPromptData={userPromptQuery.data}
desktopEnabledData={desktopEnabledQuery.data}
workspaceTTLData={workspaceTTLQuery.data}
isWorkspaceTTLLoading={workspaceTTLQuery.isLoading}
isWorkspaceTTLLoadError={workspaceTTLQuery.isError}
modelConfigsData={modelConfigsQuery.data}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
thresholds={thresholdsQuery.data?.thresholds}
isThresholdsLoading={thresholdsQuery.isLoading}
thresholdsError={thresholdsQuery.error}
onSaveThreshold={handleSaveThreshold}
onResetThreshold={handleResetThreshold}
onSaveSystemPrompt={saveSystemPromptMutation.mutate}
isSavingSystemPrompt={saveSystemPromptMutation.isPending}
isSaveSystemPromptError={saveSystemPromptMutation.isError}
onSaveUserPrompt={saveUserPromptMutation.mutate}
isSavingUserPrompt={saveUserPromptMutation.isPending}
isSaveUserPromptError={saveUserPromptMutation.isError}
onSaveDesktopEnabled={saveDesktopEnabledMutation.mutate}
isSavingDesktopEnabled={saveDesktopEnabledMutation.isPending}
isSaveDesktopEnabledError={saveDesktopEnabledMutation.isError}
onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate}
isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending}
isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError}
/>
);
};
export default AgentSettingsBehaviorPage;
@@ -0,0 +1,462 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
const mockDefaultSystemPrompt = "You are Coder, an AI coding assistant...";
// Baseline props shared across stories. Only primitives and simple
// objects here to avoid the composeStory deep-merge hang (see vault
// entry storybook-composestory-hang).
const baseProps = {
canSetSystemPrompt: true as boolean,
systemPromptData: {
system_prompt: "",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
} as TypesGen.ChatSystemPromptResponse,
userPromptData: { custom_prompt: "" } as TypesGen.UserChatCustomPrompt,
desktopEnabledData: {
enable_desktop: false,
} as TypesGen.ChatDesktopEnabledResponse,
workspaceTTLData: {
workspace_ttl_ms: 0,
} as TypesGen.ChatWorkspaceTTLResponse,
isWorkspaceTTLLoading: false,
isWorkspaceTTLLoadError: false,
modelConfigsData: [] as TypesGen.ChatModelConfig[],
modelConfigsError: undefined as unknown,
isLoadingModelConfigs: false,
thresholds: [] as readonly TypesGen.UserChatCompactionThreshold[],
isThresholdsLoading: false,
thresholdsError: undefined as unknown,
isSavingSystemPrompt: false,
isSaveSystemPromptError: false,
isSavingUserPrompt: false,
isSaveUserPromptError: false,
isSavingDesktopEnabled: false,
isSaveDesktopEnabledError: false,
isSavingWorkspaceTTL: false,
isSaveWorkspaceTTLError: false,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsBehaviorPageView",
component: AgentSettingsBehaviorPageView,
args: {
...baseProps,
onSaveSystemPrompt: fn(),
onSaveUserPrompt: fn(),
onSaveDesktopEnabled: fn(),
onSaveWorkspaceTTL: fn(),
onSaveThreshold: fn(),
onResetThreshold: fn(),
},
} satisfies Meta<typeof AgentSettingsBehaviorPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsBehaviorPageView>;
// ── Desktop ────────────────────────────────────────────────────
export const DesktopSetting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Virtual Desktop");
await canvas.findByText(
/Allow agents to use a virtual, graphical desktop/i,
);
await canvas.findByRole("switch", { name: "Enable" });
},
};
export const TogglesDesktop: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", { name: "Enable" });
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveDesktopEnabled).toHaveBeenCalledWith({
enable_desktop: true,
});
});
},
};
// ── System prompt ──────────────────────────────────────────────
export const AdminWithDefaultToggleOn: Story = {
args: {
systemPromptData: {
system_prompt: "Always use TypeScript for code examples.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).toBeChecked();
expect(
await canvas.findByDisplayValue(
"Always use TypeScript for code examples.",
),
).toBeInTheDocument();
expect(
canvas.getByText(/built-in Coder Agents prompt is prepended/i),
).toBeInTheDocument();
// Preview dialog opens and closes.
await userEvent.click(canvas.getByRole("button", { name: "Preview" }));
expect(await body.findByText("Default System Prompt")).toBeInTheDocument();
expect(body.getByText(mockDefaultSystemPrompt)).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(body.queryByText("Default System Prompt")).not.toBeInTheDocument();
});
// Toggle off include_default and save.
await userEvent.click(toggle);
const promptForm = canvas
.getByDisplayValue("Always use TypeScript for code examples.")
.closest("form")!;
const saveButton = within(promptForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
},
};
export const AdminWithDefaultToggleOff: Story = {
args: {
systemPromptData: {
system_prompt: "You are a custom assistant.",
include_default_system_prompt: false,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Include Coder Agents default system prompt",
});
expect(toggle).not.toBeChecked();
expect(
await canvas.findByDisplayValue("You are a custom assistant."),
).toBeInTheDocument();
expect(
canvas.getByText(/only the additional instructions below are used/i),
).toBeInTheDocument();
},
};
// ── Autostop ───────────────────────────────────────────────────
export const DefaultAutostopDefault: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Workspace Autostop Fallback");
await canvas.findByText(
/set a default autostop for agent-created workspaces/i,
);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).not.toBeChecked();
expect(canvas.queryByLabelText("Autostop Fallback")).toBeNull();
},
};
export const DefaultAutostopCustomValue: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop Fallback");
expect(durationInput).toHaveValue("2");
},
};
export const DefaultAutostopSave: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Toggle ON — fires immediate save with 1h default.
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 3_600_000 },
expect.anything(),
);
});
const durationInput = await canvas.findByLabelText("Autostop Fallback");
expect(durationInput).toHaveValue("1");
// Change to 3 hours.
await userEvent.clear(durationInput);
await userEvent.type(durationInput, "3");
const ttlForm = durationInput.closest("form")!;
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
// Clearing to 0 should disable Save because toggle is still ON.
await userEvent.clear(durationInput);
await waitFor(() => {
expect(saveButton).toBeDisabled();
});
},
};
export const DefaultAutostopExceedsMax: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
await userEvent.click(toggle);
const durationInput = await canvas.findByLabelText("Autostop Fallback");
const ttlForm = durationInput.closest("form")!;
// 721 hours exceeds the 30-day / 720h limit.
await userEvent.clear(durationInput);
await userEvent.type(durationInput, "721");
await waitFor(() => {
expect(canvas.getByText(/must not exceed 30 days/i)).toBeInTheDocument();
});
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
expect(saveButton).toBeDisabled();
},
};
export const DefaultAutostopToggleOff: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
},
};
export const DefaultAutostopSaveDisabled: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop Fallback");
expect(durationInput).toHaveValue("2");
const ttlForm = durationInput.closest("form")!;
const saveButton = within(ttlForm).getByRole("button", {
name: "Save",
});
expect(saveButton).toBeDisabled();
},
};
export const DefaultAutostopToggleFailure: Story = {
args: {
isSaveWorkspaceTTLError: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 3_600_000 },
expect.anything(),
);
});
// Error message should be visible.
expect(
canvas.getByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
export const DefaultAutostopToggleOffFailure: Story = {
args: {
workspaceTTLData: { workspace_ttl_ms: 7_200_000 },
isSaveWorkspaceTTLError: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable default autostop",
});
expect(toggle).toBeChecked();
const durationInput = await canvas.findByLabelText("Autostop Fallback");
expect(durationInput).toHaveValue("2");
await userEvent.click(toggle);
await waitFor(() => {
expect(args.onSaveWorkspaceTTL).toHaveBeenCalledWith(
{ workspace_ttl_ms: 0 },
expect.anything(),
);
});
// Error message should be visible.
expect(
canvas.getByText("Failed to save autostop setting."),
).toBeInTheDocument();
},
};
export const DefaultAutostopNotVisibleToNonAdmin: Story = {
args: {
canSetSystemPrompt: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Personal Instructions should be visible.
await canvas.findByText("Personal Instructions");
// Admin-only sections should not be present.
expect(canvas.queryByText("Workspace Autostop Fallback")).toBeNull();
expect(canvas.queryByText("Virtual Desktop")).toBeNull();
expect(canvas.queryByText("System Instructions")).toBeNull();
},
};
// ── Invisible Unicode warnings ─────────────────────────────────
export const InvisibleUnicodeWarningSystemPrompt: Story = {
args: {
systemPromptData: {
system_prompt:
"Normal prompt text\u200b\u200b\u200b\u200bhidden instruction",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("System Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("4");
},
};
export const InvisibleUnicodeWarningUserPrompt: Story = {
args: {
userPromptData: {
custom_prompt: "My custom prompt\u200b\u200c\u200dhidden",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Personal Instructions");
const alert = await canvas.findByText(/invisible Unicode/);
expect(alert).toBeInTheDocument();
expect(alert.textContent).toContain("2");
},
};
export const InvisibleUnicodeWarningOnType: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const textarea = await canvas.findByPlaceholderText(
"Additional behavior, style, and tone preferences",
);
// No warning initially.
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
// Type a string containing a ZWS character.
await userEvent.type(textarea, "hello\u200bworld");
await waitFor(() => {
expect(canvas.getByText(/invisible Unicode/)).toBeInTheDocument();
});
},
};
export const NoWarningForCleanPrompt: Story = {
args: {
systemPromptData: {
system_prompt: "You are a helpful coding assistant.",
include_default_system_prompt: true,
default_system_prompt: mockDefaultSystemPrompt,
},
userPromptData: {
custom_prompt: "Be concise and use TypeScript.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Personal Instructions");
await canvas.findByText("System Instructions");
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
},
};
@@ -0,0 +1,522 @@
import type { FC, FormEvent } from "react";
import { useMemo, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { Switch } from "#/components/Switch/Switch";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { AdminBadge } from "./components/AdminBadge";
import { DurationField } from "./components/DurationField/DurationField";
import { SectionHeader } from "./components/SectionHeader";
import { TextPreviewDialog } from "./components/TextPreviewDialog";
import { UserCompactionThresholdSettings } from "./components/UserCompactionThresholdSettings";
const textareaMaxHeight = 240;
const textareaBaseClassName =
"max-h-[240px] w-full resize-none rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30";
const textareaOverflowClassName = "overflow-y-auto [scrollbar-width:thin]";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface AgentSettingsBehaviorPageViewProps {
canSetSystemPrompt: boolean;
// Raw query data
systemPromptData: TypesGen.ChatSystemPromptResponse | undefined;
userPromptData: TypesGen.UserChatCustomPrompt | undefined;
desktopEnabledData: TypesGen.ChatDesktopEnabledResponse | undefined;
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
// Thresholds (passed through to child component)
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
isThresholdsLoading: boolean;
thresholdsError: unknown;
onSaveThreshold: (
modelConfigId: string,
thresholdPercent: number,
) => Promise<unknown>;
onResetThreshold: (modelConfigId: string) => Promise<unknown>;
// Mutation handlers
onSaveSystemPrompt: (
req: TypesGen.UpdateChatSystemPromptRequest,
options?: MutationCallbacks,
) => void;
isSavingSystemPrompt: boolean;
isSaveSystemPromptError: boolean;
onSaveUserPrompt: (
req: TypesGen.UserChatCustomPrompt,
options?: MutationCallbacks,
) => void;
isSavingUserPrompt: boolean;
isSaveUserPromptError: boolean;
onSaveDesktopEnabled: (
req: TypesGen.UpdateChatDesktopEnabledRequest,
options?: MutationCallbacks,
) => void;
isSavingDesktopEnabled: boolean;
isSaveDesktopEnabledError: boolean;
onSaveWorkspaceTTL: (
req: TypesGen.UpdateChatWorkspaceTTLRequest,
options?: MutationCallbacks,
) => void;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
}
export const AgentSettingsBehaviorPageView: FC<
AgentSettingsBehaviorPageViewProps
> = ({
canSetSystemPrompt,
systemPromptData,
userPromptData,
desktopEnabledData,
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
thresholds,
isThresholdsLoading,
thresholdsError,
onSaveThreshold,
onResetThreshold,
onSaveSystemPrompt,
isSavingSystemPrompt,
isSaveSystemPromptError,
onSaveUserPrompt,
isSavingUserPrompt,
isSaveUserPromptError,
onSaveDesktopEnabled,
isSavingDesktopEnabled,
isSaveDesktopEnabledError,
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
}) => {
// ── Local form state ──
const [localEdit, setLocalEdit] = useState<string | null>(null);
const [localIncludeDefault, setLocalIncludeDefault] = useState<
boolean | null
>(null);
const [showDefaultPromptPreview, setShowDefaultPromptPreview] =
useState(false);
const [localUserEdit, setLocalUserEdit] = useState<string | null>(null);
const [localTTLMs, setLocalTTLMs] = useState<number | null>(null);
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
// Overflow states are pure UI — managed locally in the view.
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
const [isSystemPromptOverflowing, setIsSystemPromptOverflowing] =
useState(false);
// ── Derived state ──
const hasLoadedSystemPrompt = systemPromptData !== undefined;
const serverPrompt = systemPromptData?.system_prompt ?? "";
const serverIncludeDefault = systemPromptData?.include_default_system_prompt;
const defaultSystemPrompt = systemPromptData?.default_system_prompt ?? "";
const systemPromptDraft = localEdit ?? serverPrompt;
const includeDefaultDraft =
localIncludeDefault ?? serverIncludeDefault ?? false;
const serverUserPrompt = userPromptData?.custom_prompt ?? "";
const userPromptDraft = localUserEdit ?? serverUserPrompt;
const systemInvisibleCharCount = useMemo(
() => countInvisibleCharacters(systemPromptDraft),
[systemPromptDraft],
);
const userInvisibleCharCount = useMemo(
() => countInvisibleCharacters(userPromptDraft),
[userPromptDraft],
);
const isPromptSaving = isSavingSystemPrompt || isSavingUserPrompt;
const isSystemPromptDirty =
hasLoadedSystemPrompt &&
((localEdit !== null && localEdit !== serverPrompt) ||
(localIncludeDefault !== null &&
localIncludeDefault !== serverIncludeDefault));
const isSystemPromptDisabled = isPromptSaving || !hasLoadedSystemPrompt;
const isUserPromptDirty =
localUserEdit !== null && localUserEdit !== serverUserPrompt;
const desktopEnabled = desktopEnabledData?.enable_desktop ?? false;
const serverTTLMs = workspaceTTLData?.workspace_ttl_ms ?? 0;
const ttlMs = localTTLMs ?? serverTTLMs;
const isAutostopEnabled = autostopToggled ?? serverTTLMs > 0;
const isTTLDirty = localTTLMs !== null && localTTLMs !== serverTTLMs;
const maxTTLMs = 30 * 24 * 60 * 60_000; // 30 days
const isTTLOverMax = ttlMs > maxTTLMs;
const isTTLZero = isAutostopEnabled && ttlMs === 0;
// ── Event handlers ──
const handleSaveSystemPrompt = (event: FormEvent) => {
event.preventDefault();
if (!hasLoadedSystemPrompt || !isSystemPromptDirty) return;
onSaveSystemPrompt(
{
system_prompt: systemPromptDraft,
include_default_system_prompt: includeDefaultDraft,
},
{
onSuccess: () => {
setLocalEdit(null);
setLocalIncludeDefault(null);
},
},
);
};
const handleSaveUserPrompt = (event: FormEvent) => {
event.preventDefault();
if (!isUserPromptDirty) return;
onSaveUserPrompt(
{ custom_prompt: userPromptDraft },
{ onSuccess: () => setLocalUserEdit(null) },
);
};
const resetAutostopState = () => {
setLocalTTLMs(null);
setAutostopToggled(null);
};
const handleToggleAutostop = (checked: boolean) => {
if (checked) {
// Defensive: restore server value if query cache is
// stale; otherwise default to 1 hour.
const defaultTTL = serverTTLMs > 0 ? serverTTLMs : 3_600_000;
setAutostopToggled(true);
setLocalTTLMs(defaultTTL);
onSaveWorkspaceTTL(
{ workspace_ttl_ms: defaultTTL },
{ onSuccess: resetAutostopState, onError: resetAutostopState },
);
} else {
setAutostopToggled(false);
setLocalTTLMs(0);
onSaveWorkspaceTTL(
{ workspace_ttl_ms: 0 },
{ onSuccess: resetAutostopState, onError: resetAutostopState },
);
}
};
const handleSaveChatWorkspaceTTL = (event: FormEvent) => {
event.preventDefault();
if (!isTTLDirty || isSavingWorkspaceTTL) return;
onSaveWorkspaceTTL(
{ workspace_ttl_ms: localTTLMs ?? 0 },
{
onSuccess: resetAutostopState,
onError: () => setAutostopToggled(null),
},
);
};
const handleTTLChange = (value: number) => {
setLocalTTLMs(value);
// Latch the toggle open while the user is editing
// so a background refetch cannot unmount the field.
if (autostopToggled === null) {
setAutostopToggled(true);
}
};
return (
<>
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your chats."
/>
{/* ── Personal prompt (always visible) ── */}
<form
className="space-y-2"
onSubmit={(event) => void handleSaveUserPrompt(event)}
>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Personal Instructions
</h3>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
Applied to all your chats. Only visible to you.
</p>
<TextareaAutosize
className={cn(
textareaBaseClassName,
isUserPromptOverflowing && textareaOverflowClassName,
)}
placeholder="Additional behavior, style, and tone preferences"
value={userPromptDraft}
onChange={(event) => setLocalUserEdit(event.target.value)}
onHeightChange={(height) =>
setIsUserPromptOverflowing(height >= textareaMaxHeight)
}
disabled={isPromptSaving}
minRows={1}
/>
{userInvisibleCharCount > 0 && (
<Alert severity="warning">
This text contains {userInvisibleCharCount} invisible Unicode{" "}
{userInvisibleCharCount !== 1 ? "characters" : "character"} that
could hide content. These will be stripped on save.
</Alert>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalUserEdit("")}
disabled={isPromptSaving || !userPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isPromptSaving || !isUserPromptDirty}
>
Save
</Button>
</div>
{isSaveUserPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal instructions.
</p>
)}
</form>
<hr className="my-5 border-0 border-t border-solid border-border" />
<UserCompactionThresholdSettings
modelConfigs={modelConfigsData ?? []}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
thresholds={thresholds}
isThresholdsLoading={isThresholdsLoading}
thresholdsError={thresholdsError}
onSaveThreshold={onSaveThreshold}
onResetThreshold={onResetThreshold}
/>
{/* ── Admin system prompt (admin only) ── */}
{canSetSystemPrompt && (
<>
<hr className="my-5 border-0 border-t border-solid border-border" />
<form
className="space-y-2"
onSubmit={(event) => void handleSaveSystemPrompt(event)}
>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
System Instructions
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-content-primary">
<span>Include Coder Agents default system prompt</span>
<Button
size="xs"
variant="subtle"
type="button"
onClick={() => setShowDefaultPromptPreview(true)}
disabled={!hasLoadedSystemPrompt}
className="min-w-0 px-0 text-content-link hover:text-content-link"
>
Preview
</Button>
</div>
<Switch
checked={includeDefaultDraft}
onCheckedChange={setLocalIncludeDefault}
aria-label="Include Coder Agents default system prompt"
disabled={isSystemPromptDisabled}
/>
</div>
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
{includeDefaultDraft
? "The built-in Coder Agents prompt is prepended. Additional instructions below are appended."
: "Only the additional instructions below are used. When empty, no deployment-wide system prompt is sent."}
</p>
<TextareaAutosize
className={cn(
textareaBaseClassName,
isSystemPromptOverflowing && textareaOverflowClassName,
)}
placeholder="Additional instructions for all users"
value={systemPromptDraft}
onChange={(event) => setLocalEdit(event.target.value)}
onHeightChange={(height) =>
setIsSystemPromptOverflowing(height >= textareaMaxHeight)
}
disabled={isSystemPromptDisabled}
minRows={1}
/>
{systemInvisibleCharCount > 0 && (
<Alert severity="warning">
This text contains {systemInvisibleCharCount} invisible Unicode{" "}
{systemInvisibleCharCount !== 1 ? "characters" : "character"}{" "}
that could hide content. These will be stripped on save.
</Alert>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => setLocalEdit("")}
disabled={isSystemPromptDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isSystemPromptDisabled || !isSystemPromptDirty}
>
Save
</Button>{" "}
</div>
{isSaveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</form>
<hr className="my-5 border-0 border-t border-solid border-border" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Virtual Desktop
</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">
<p className="m-0">
Allow agents to use a virtual, graphical desktop within
workspaces. Requires the{" "}
<Link
href="https://registry.coder.com/modules/coder/portabledesktop"
target="_blank"
size="sm"
>
portabledesktop module
</Link>{" "}
to be installed in the workspace and the Anthropic provider to
be configured.
</p>
<p className="mt-2 mb-0 font-semibold text-content-secondary">
Warning: This is a work-in-progress feature, and you're likely
to encounter bugs if you enable it.
</p>
</div>
<Switch
checked={desktopEnabled}
onCheckedChange={(checked) =>
onSaveDesktopEnabled({ enable_desktop: checked })
}
aria-label="Enable"
disabled={isSavingDesktopEnabled}
/>
</div>
{isSaveDesktopEnabledError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save desktop setting.
</p>
)}
</div>
<hr className="my-5 border-0 border-t border-solid border-border" />
<form
className="space-y-2"
onSubmit={(event) => void handleSaveChatWorkspaceTTL(event)}
>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Workspace Autostop Fallback
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Set a default autostop for agent-created workspaces that don't
have one defined in their template. Template-defined autostop
rules always take precedence. Active chats will extend the stop
time.
</p>
<Switch
checked={isAutostopEnabled}
onCheckedChange={handleToggleAutostop}
aria-label="Enable default autostop"
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
/>{" "}
</div>
{isAutostopEnabled && (
<DurationField
valueMs={ttlMs}
onChange={handleTTLChange}
label="Autostop Fallback"
disabled={isSavingWorkspaceTTL || isWorkspaceTTLLoading}
error={isTTLOverMax || isTTLZero}
helperText={
isTTLZero
? "Duration must be greater than zero."
: isTTLOverMax
? "Must not exceed 30 days (720 hours)."
: undefined
}
/>
)}
{isAutostopEnabled && (
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={
isSavingWorkspaceTTL ||
!isTTLDirty ||
isTTLOverMax ||
isTTLZero
}
>
Save
</Button>
</div>
)}
{isSaveWorkspaceTTLError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save autostop setting.
</p>
)}
{isWorkspaceTTLLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load autostop setting.
</p>
)}
</form>
</>
)}
{showDefaultPromptPreview && (
<TextPreviewDialog
content={defaultSystemPrompt}
fileName="Default System Prompt"
onClose={() => setShowDefaultPromptPreview(false)}
/>
)}
</>
);
};
@@ -0,0 +1,57 @@
import dayjs, { type Dayjs } from "dayjs";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { prInsights } from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { InsightsContent } from "./components/InsightsContent";
import type { PRInsightsTimeRange } from "./components/PRInsightsView";
type TimeRangeSelection = {
timeRange: PRInsightsTimeRange;
anchor: Dayjs;
};
function timeRangeToDates(range: PRInsightsTimeRange, anchor: Dayjs) {
const days = Number.parseInt(range, 10);
const start = anchor.subtract(days, "day");
return {
start_date: start.toISOString(),
end_date: anchor.toISOString(),
};
}
const AgentSettingsInsightsPage: FC = () => {
const { permissions } = useAuthenticated();
const [selection, setSelection] = useState<TimeRangeSelection>(() => ({
timeRange: "30d",
anchor: dayjs(),
}));
const dates = timeRangeToDates(selection.timeRange, selection.anchor);
const { data, isLoading, error } = useQuery(prInsights(dates));
const handleTimeRangeChange = (timeRange: PRInsightsTimeRange) =>
setSelection((current) =>
current.timeRange === timeRange
? current
: {
timeRange,
anchor: dayjs(),
},
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<InsightsContent
data={data}
isLoading={isLoading}
error={error}
timeRange={selection.timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
</RequirePermission>
);
};
export default AgentSettingsInsightsPage;
@@ -0,0 +1,94 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatUsageLimitConfig,
deleteChatUsageLimitGroupOverride,
deleteChatUsageLimitOverride,
updateChatUsageLimitConfig,
upsertChatUsageLimitGroupOverride,
upsertChatUsageLimitOverride,
} from "#/api/queries/chats";
import { groups } from "#/api/queries/groups";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { LimitsTab } from "./components/LimitsTab";
const AgentSettingsLimitsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// Queries.
const configQuery = useQuery(chatUsageLimitConfig());
const groupsQuery = useQuery(groups());
// Mutations.
const updateConfigMutation = useMutation(
updateChatUsageLimitConfig(queryClient),
);
const upsertOverrideMutation = useMutation(
upsertChatUsageLimitOverride(queryClient),
);
const deleteOverrideMutation = useMutation(
deleteChatUsageLimitOverride(queryClient),
);
const upsertGroupOverrideMutation = useMutation(
upsertChatUsageLimitGroupOverride(queryClient),
);
const deleteGroupOverrideMutation = useMutation(
deleteChatUsageLimitGroupOverride(queryClient),
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<LimitsTab
configData={configQuery.data}
isLoadingConfig={configQuery.isLoading}
configError={configQuery.isError ? configQuery.error : null}
refetchConfig={() => void configQuery.refetch()}
groupsData={groupsQuery.data}
isLoadingGroups={groupsQuery.isLoading}
groupsError={groupsQuery.isError ? groupsQuery.error : null}
onUpdateConfig={(req) => updateConfigMutation.mutateAsync(req)}
isUpdatingConfig={updateConfigMutation.isPending}
updateConfigError={
updateConfigMutation.isError ? updateConfigMutation.error : null
}
isUpdateConfigSuccess={updateConfigMutation.isSuccess}
resetUpdateConfig={() => updateConfigMutation.reset()}
onUpsertOverride={(args) => upsertOverrideMutation.mutateAsync(args)}
isUpsertingOverride={upsertOverrideMutation.isPending}
upsertOverrideError={
upsertOverrideMutation.isError ? upsertOverrideMutation.error : null
}
onDeleteOverride={(userID) =>
deleteOverrideMutation.mutateAsync(userID)
}
isDeletingOverride={deleteOverrideMutation.isPending}
deleteOverrideError={
deleteOverrideMutation.isError ? deleteOverrideMutation.error : null
}
onUpsertGroupOverride={(args) =>
upsertGroupOverrideMutation.mutateAsync(args)
}
isUpsertingGroupOverride={upsertGroupOverrideMutation.isPending}
upsertGroupOverrideError={
upsertGroupOverrideMutation.isError
? upsertGroupOverrideMutation.error
: null
}
onDeleteGroupOverride={(groupID) =>
deleteGroupOverrideMutation.mutateAsync(groupID)
}
isDeletingGroupOverride={deleteGroupOverrideMutation.isPending}
deleteGroupOverrideError={
deleteGroupOverrideMutation.isError
? deleteGroupOverrideMutation.error
: null
}
/>
</RequirePermission>
);
};
export default AgentSettingsLimitsPage;
@@ -0,0 +1,47 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
createMCPServerConfig,
deleteMCPServerConfig,
mcpServerConfigs,
updateMCPServerConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
const AgentSettingsMCPServersPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const serversQuery = useQuery(mcpServerConfigs());
const createServerMutation = useMutation(createMCPServerConfig(queryClient));
const updateServerMutation = useMutation(updateMCPServerConfig(queryClient));
const deleteServerMutation = useMutation(deleteMCPServerConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<MCPServerAdminPanel
sectionLabel="MCP Servers"
sectionDescription="Configure external MCP servers that provide additional tools for AI chat sessions."
sectionBadge={<AdminBadge />}
serversData={serversQuery.data}
isLoadingServers={serversQuery.isLoading}
serversError={serversQuery.isError ? serversQuery.error : null}
onCreateServer={(req) => createServerMutation.mutateAsync(req)}
onUpdateServer={(args) => updateServerMutation.mutateAsync(args)}
onDeleteServer={(id) => deleteServerMutation.mutateAsync(id)}
isCreatingServer={createServerMutation.isPending}
isUpdatingServer={updateServerMutation.isPending}
isDeletingServer={deleteServerMutation.isPending}
createError={createServerMutation.error}
updateError={updateServerMutation.error}
deleteError={deleteServerMutation.error}
/>
</RequirePermission>
);
};
export default AgentSettingsMCPServersPage;
@@ -0,0 +1,100 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
createChatModelConfig,
createChatProviderConfig,
deleteChatModelConfig,
deleteChatProviderConfig,
updateChatModelConfig,
updateChatProviderConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
const AgentSettingsModelsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// Queries.
const providerConfigsQuery = useQuery(chatProviderConfigs());
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
// Mutations.
const createProviderMutation = useMutation(
createChatProviderConfig(queryClient),
);
const updateProviderMutation = useMutation(
updateChatProviderConfig(queryClient),
);
const deleteProviderMutation = useMutation(
deleteChatProviderConfig(queryClient),
);
const createModelMutation = useMutation(createChatModelConfig(queryClient));
const updateModelMutation = useMutation(updateChatModelConfig(queryClient));
const deleteModelMutation = useMutation(deleteChatModelConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
providerConfigsData={providerConfigsQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelCatalogData={modelCatalogQuery.data}
isLoading={
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading
}
providerConfigsError={
providerConfigsQuery.isError ? providerConfigsQuery.error : null
}
modelConfigsError={
modelConfigsQuery.isError ? modelConfigsQuery.error : null
}
modelCatalogError={
modelCatalogQuery.isError ? modelCatalogQuery.error : null
}
onCreateProvider={(req) => createProviderMutation.mutateAsync(req)}
onUpdateProvider={(providerConfigId, req) =>
updateProviderMutation.mutateAsync({ providerConfigId, req })
}
onDeleteProvider={(id) => deleteProviderMutation.mutateAsync(id)}
isProviderMutationPending={
createProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending
}
providerMutationError={
createProviderMutation.error ??
updateProviderMutation.error ??
deleteProviderMutation.error
}
onCreateModel={(req) => createModelMutation.mutateAsync(req)}
onUpdateModel={(modelConfigId, req) =>
updateModelMutation.mutateAsync({ modelConfigId, req })
}
onDeleteModel={(id) => deleteModelMutation.mutateAsync(id)}
isCreatingModel={createModelMutation.isPending}
isUpdatingModel={updateModelMutation.isPending}
isDeletingModel={deleteModelMutation.isPending}
modelMutationError={
createModelMutation.error ??
updateModelMutation.error ??
deleteModelMutation.error
}
/>
</RequirePermission>
);
};
export default AgentSettingsModelsPage;
@@ -1,13 +1,10 @@
import type { FC } from "react";
import { useParams } from "react-router";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { AgentSettingsPageView } from "./AgentSettingsPageView";
import { Outlet, useParams } from "react-router";
import { AgentPageHeader } from "./components/AgentPageHeader";
const AgentSettingsPage: FC = () => {
const { section } = useParams();
const { permissions } = useAuthenticated();
const isAgentsAdmin = permissions.editDeploymentConfig;
const { "*": section } = useParams();
return (
<>
<AgentPageHeader
@@ -15,11 +12,11 @@ const AgentSettingsPage: FC = () => {
section ? { to: "/agents/settings", label: "Settings" } : undefined
}
/>
<AgentSettingsPageView
activeSection={section ?? "behavior"}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="mx-auto w-full max-w-3xl">
<Outlet />
</div>
</div>
</>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
createChatModelConfig,
createChatProviderConfig,
deleteChatModelConfig,
deleteChatProviderConfig,
updateChatModelConfig,
updateChatProviderConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AdminBadge } from "./components/AdminBadge";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
const AgentSettingsProvidersPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// Queries.
const providerConfigsQuery = useQuery(chatProviderConfigs());
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
// Mutations.
const createProviderMutation = useMutation(
createChatProviderConfig(queryClient),
);
const updateProviderMutation = useMutation(
updateChatProviderConfig(queryClient),
);
const deleteProviderMutation = useMutation(
deleteChatProviderConfig(queryClient),
);
const createModelMutation = useMutation(createChatModelConfig(queryClient));
const updateModelMutation = useMutation(updateChatModelConfig(queryClient));
const deleteModelMutation = useMutation(deleteChatModelConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<ChatModelAdminPanel
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their chats."
sectionBadge={<AdminBadge />}
providerConfigsData={providerConfigsQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelCatalogData={modelCatalogQuery.data}
isLoading={
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading
}
providerConfigsError={
providerConfigsQuery.isError ? providerConfigsQuery.error : null
}
modelConfigsError={
modelConfigsQuery.isError ? modelConfigsQuery.error : null
}
modelCatalogError={
modelCatalogQuery.isError ? modelCatalogQuery.error : null
}
onCreateProvider={(req) => createProviderMutation.mutateAsync(req)}
onUpdateProvider={(providerConfigId, req) =>
updateProviderMutation.mutateAsync({ providerConfigId, req })
}
onDeleteProvider={(id) => deleteProviderMutation.mutateAsync(id)}
isProviderMutationPending={
createProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending
}
providerMutationError={
createProviderMutation.error ??
updateProviderMutation.error ??
deleteProviderMutation.error
}
onCreateModel={(req) => createModelMutation.mutateAsync(req)}
onUpdateModel={(modelConfigId, req) =>
updateModelMutation.mutateAsync({ modelConfigId, req })
}
onDeleteModel={(id) => deleteModelMutation.mutateAsync(id)}
isCreatingModel={createModelMutation.isPending}
isUpdatingModel={updateModelMutation.isPending}
isDeletingModel={deleteModelMutation.isPending}
modelMutationError={
createModelMutation.error ??
updateModelMutation.error ??
deleteModelMutation.error
}
/>
</RequirePermission>
);
};
export default AgentSettingsProvidersPage;
@@ -0,0 +1,43 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatTemplateAllowlist,
updateChatTemplateAllowlist,
} from "#/api/queries/chats";
import { templates } from "#/api/queries/templates";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView";
const AgentSettingsTemplatesPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const templatesQuery = useQuery(templates());
const allowlistQuery = useQuery(chatTemplateAllowlist());
const saveAllowlistMutation = useMutation(
updateChatTemplateAllowlist(queryClient),
);
const isLoading = templatesQuery.isLoading || allowlistQuery.isLoading;
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsTemplatesPageView
templatesData={templatesQuery.data}
allowlistData={allowlistQuery.data}
isLoading={isLoading}
hasError={!!(templatesQuery.error || allowlistQuery.error)}
onRetry={() => {
void templatesQuery.refetch();
void allowlistQuery.refetch();
}}
onSaveAllowlist={saveAllowlistMutation.mutate}
isSaving={saveAllowlistMutation.isPending}
isSaveError={saveAllowlistMutation.isError}
/>
</RequirePermission>
);
};
export default AgentSettingsTemplatesPage;
@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { MockTemplate } from "#/testHelpers/entities";
import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView";
const manyTemplates = [
{ id: "t-01", name: "docker-dev", display_name: "Docker Development" },
{
id: "t-02",
name: "kubernetes-prod",
display_name: "Kubernetes Production",
},
{ id: "t-03", name: "aws-windows", display_name: "AWS Windows Desktop" },
{ id: "t-04", name: "gcp-linux", display_name: "GCP Linux Workspace" },
{
id: "t-05",
name: "azure-dotnet",
display_name: "Azure .NET Environment",
},
{ id: "t-06", name: "ml-jupyter", display_name: "ML Jupyter Notebook" },
{
id: "t-07",
name: "data-eng-spark",
display_name: "Data Engineering (Spark)",
},
{
id: "t-08",
name: "frontend-vite",
display_name: "Frontend (Vite + React)",
},
].map((t) => ({ ...MockTemplate, ...t }));
const meta = {
title: "pages/AgentsPage/AgentSettingsTemplatesPageView",
component: AgentSettingsTemplatesPageView,
args: {
templatesData: manyTemplates,
allowlistData: { template_ids: [] },
isLoading: false,
hasError: false,
isSaving: false,
isSaveError: false,
onRetry: fn(),
onSaveAllowlist: fn(),
},
} satisfies Meta<typeof AgentSettingsTemplatesPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsTemplatesPageView>;
export const TemplateAllowlist: Story = {
play: async ({ canvasElement, step, args }) => {
const canvas = within(canvasElement);
await step("starts empty", async () => {
await canvas.findByText(/no templates selected/i);
const saveBtn = await canvas.findByRole("button", {
name: "Save",
});
expect(saveBtn).toBeDisabled();
});
await step("select one template and save", async () => {
const input = canvas.getByPlaceholderText("Select templates...");
await userEvent.click(input);
await userEvent.click(
await canvas.findByRole("option", {
name: "Docker Development",
}),
);
await waitFor(() => {
expect(canvas.getByText("1 template selected")).toBeInTheDocument();
});
const saveBtn = canvas.getByRole("button", { name: "Save" });
expect(saveBtn).toBeEnabled();
await userEvent.click(saveBtn);
await waitFor(() => {
expect(args.onSaveAllowlist).toHaveBeenCalledWith(
{ template_ids: ["t-01"] },
expect.anything(),
);
});
});
await step("add the remaining seven and save", async () => {
const input = canvas.getByLabelText("Select allowed templates");
await userEvent.click(input);
for (const name of [
"Kubernetes Production",
"AWS Windows Desktop",
"GCP Linux Workspace",
"Azure .NET Environment",
"ML Jupyter Notebook",
"Data Engineering (Spark)",
"Frontend (Vite + React)",
]) {
await userEvent.click(await canvas.findByRole("option", { name }));
}
await waitFor(() => {
expect(canvas.getByText("8 templates selected")).toBeInTheDocument();
});
const saveBtn = canvas.getByRole("button", { name: "Save" });
await userEvent.click(saveBtn);
await waitFor(() => {
expect(args.onSaveAllowlist).toHaveBeenLastCalledWith(
{
template_ids: expect.arrayContaining([
"t-01",
"t-02",
"t-03",
"t-04",
"t-05",
"t-06",
"t-07",
"t-08",
]),
},
expect.anything(),
);
});
});
},
};
@@ -0,0 +1,158 @@
import { type FC, type FormEvent, useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
MultiSelectCombobox,
type Option,
} from "#/components/MultiSelectCombobox/MultiSelectCombobox";
import { Spinner } from "#/components/Spinner/Spinner";
import { AdminBadge } from "./components/AdminBadge";
import { SectionHeader } from "./components/SectionHeader";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface AgentSettingsTemplatesPageViewProps {
// Raw query data
templatesData: TypesGen.Template[] | undefined;
allowlistData: TypesGen.ChatTemplateAllowlist | undefined;
isLoading: boolean;
hasError: boolean;
onRetry: () => void;
// Mutation
onSaveAllowlist: (
req: TypesGen.ChatTemplateAllowlist,
options?: MutationCallbacks,
) => void;
isSaving: boolean;
isSaveError: boolean;
}
export const AgentSettingsTemplatesPageView: FC<
AgentSettingsTemplatesPageViewProps
> = ({
templatesData,
allowlistData,
isLoading,
hasError,
onRetry,
onSaveAllowlist,
isSaving,
isSaveError,
}) => {
// ── Local form state ──
const [localSelection, setLocalSelection] = useState<Option[] | null>(null);
// ── Derived state ──
const allOptions: Option[] = (templatesData ?? []).map((t) => ({
value: t.id,
label: t.display_name || t.name,
icon: t.icon,
}));
const optionsByID = new Map(allOptions.map((o) => [o.value, o]));
const serverSelection: Option[] = (allowlistData?.template_ids ?? [])
.map((id) => optionsByID.get(id))
.filter((o) => o !== undefined);
const currentSelection = localSelection ?? serverSelection;
const serverSet = new Set(serverSelection.map((o) => o.value));
const isDirty =
localSelection !== null &&
(localSelection.length !== serverSet.size ||
localSelection.some((o) => !serverSet.has(o.value)));
const serverSelectionKey = serverSelection.map((o) => o.value).join(",");
// ── Event handlers ──
const handleSave = (event: FormEvent) => {
event.preventDefault();
if (!isDirty) return;
onSaveAllowlist(
{ template_ids: currentSelection.map((o) => o.value) },
{ onSuccess: () => setLocalSelection(null) },
);
};
return (
<div className="space-y-6">
<SectionHeader
label="Templates"
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
badge={<AdminBadge />}
/>
{isLoading && (
<div
role="status"
aria-label="Loading templates"
className="flex min-h-[120px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{!isLoading && hasError && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
Failed to load template data.
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
)}
{!isLoading && !hasError && (
<form
className="space-y-3"
onSubmit={(event) => void handleSave(event)}
>
<MultiSelectCombobox
key={serverSelectionKey}
inputProps={{ "aria-label": "Select allowed templates" }}
options={allOptions}
defaultOptions={currentSelection}
value={currentSelection}
onChange={setLocalSelection}
placeholder="Select templates..."
emptyIndicator={
<p className="text-center text-sm text-content-secondary">
No templates found.
</p>
}
disabled={isSaving}
hidePlaceholderWhenSelected
data-testid="template-allowlist-select"
/>
<p
aria-live="polite"
role="status"
className="m-0 text-xs text-content-secondary"
>
{currentSelection.length > 0
? `${currentSelection.length} template${currentSelection.length !== 1 ? "s" : ""} selected`
: "No templates selected \u2014 all templates are available"}
</p>
<div className="flex justify-end">
<Button size="sm" type="submit" disabled={isSaving || !isDirty}>
Save
</Button>
</div>
{isSaveError && (
<p role="alert" className="m-0 text-xs text-content-destructive">
Failed to save template allowlist.
</p>
)}
</form>
)}
</div>
);
};
@@ -0,0 +1,147 @@
import dayjs from "dayjs";
import { type FC, useState } from "react";
import { keepPreviousData, useQuery } from "react-query";
import { useSearchParams } from "react-router";
import { chatCostSummary, chatCostUsers } from "#/api/queries/chats";
import { user } from "#/api/queries/users";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { useDebouncedValue } from "#/hooks/debounce";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
const pageSize = 10;
const usageStartDateSearchParam = "startDate";
const usageEndDateSearchParam = "endDate";
const getDefaultUsageDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
const end = now ?? dayjs();
return {
startDate: end.subtract(30, "day").toDate(),
endDate: end.toDate(),
};
};
interface AgentSettingsUsagePageProps {
/** Override the current time for date range calculation. Used for
* deterministic Storybook snapshots. */
now?: dayjs.Dayjs;
}
const AgentSettingsUsagePage: FC<AgentSettingsUsagePageProps> = ({ now }) => {
const { permissions } = useAuthenticated();
const [searchParams, setSearchParams] = useSearchParams();
const [searchFilter, setSearchFilter] = useState("");
const debouncedSearch = useDebouncedValue(searchFilter, 300);
const [page, setPage] = useState(1);
const startDateParam =
searchParams.get(usageStartDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(usageEndDateSearchParam)?.trim() ?? "";
const [defaultDateRange] = useState(() => getDefaultUsageDateRange(now));
let dateRange = defaultDateRange;
let hasExplicitDateRange = false;
if (startDateParam && endDateParam) {
const parsedStartDate = new Date(startDateParam);
const parsedEndDate = new Date(endDateParam);
if (
!Number.isNaN(parsedStartDate.getTime()) &&
!Number.isNaN(parsedEndDate.getTime()) &&
parsedStartDate.getTime() <= parsedEndDate.getTime()
) {
dateRange = {
startDate: parsedStartDate,
endDate: parsedEndDate,
};
hasExplicitDateRange = true;
}
}
const dateRangeParams = {
start_date: dateRange.startDate.toISOString(),
end_date: dateRange.endDate.toISOString(),
};
const offset = (page - 1) * pageSize;
const onDateRangeChange = (value: DateRangeValue) => {
// Reset pagination but preserve user selection and other params.
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set(usageStartDateSearchParam, value.startDate.toISOString());
next.set(usageEndDateSearchParam, value.endDate.toISOString());
return next;
});
};
const usersQuery = useQuery({
...chatCostUsers({
...dateRangeParams,
username: debouncedSearch || undefined,
limit: pageSize,
offset,
}),
placeholderData: keepPreviousData,
});
const selectedUserId = searchParams.get("user");
const selectedUserQuery = useQuery({
...user(selectedUserId ?? ""),
enabled: selectedUserId !== null,
});
const summaryQuery = useQuery({
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
enabled: selectedUserId !== null,
});
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsUsagePageView
dateRange={dateRange}
hasExplicitDateRange={hasExplicitDateRange}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
page={page}
onPageChange={setPage}
pageSize={pageSize}
offset={offset}
usersData={usersQuery.data}
isUsersLoading={usersQuery.isLoading}
isUsersFetching={usersQuery.isFetching}
usersError={usersQuery.error}
onUsersRetry={() => void usersQuery.refetch()}
selectedUserId={selectedUserId}
selectedUser={selectedUserQuery.data ?? null}
isSelectedUserLoading={selectedUserQuery.isLoading}
isSelectedUserError={selectedUserQuery.isError}
selectedUserError={selectedUserQuery.error}
onSelectedUserRetry={() => void selectedUserQuery.refetch()}
onClearSelectedUser={() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("user");
return next;
});
}}
onSelectUser={(u) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("user", u.user_id);
return next;
});
}}
summaryData={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
summaryError={summaryQuery.error}
onSummaryRetry={() => void summaryQuery.refetch()}
/>
</RequirePermission>
);
};
export default AgentSettingsUsagePage;
@@ -0,0 +1,247 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
const mockUsers: TypesGen.ChatCostUserRollup[] = [
{
user_id: "user-1",
username: "alice",
name: "Alice Liddell",
avatar_url: "",
total_cost_micros: 2_500_000,
message_count: 42,
chat_count: 5,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
},
{
user_id: "user-2",
username: "bob",
name: "Bob Builder",
avatar_url: "",
total_cost_micros: 1_000_000,
message_count: 18,
chat_count: 3,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 4_000,
total_cache_creation_tokens: 2_000,
},
];
const mockUsersResponse: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: mockUsers.length,
users: mockUsers,
};
const mockUserProfile: TypesGen.User = {
id: "user-1",
username: "alice",
name: "Alice Liddell",
email: "alice@example.com",
avatar_url: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-06-01T00:00:00Z",
status: "active",
organization_ids: [],
roles: [],
last_seen_at: "2026-03-11T10:00:00Z",
login_type: "password",
has_ai_seat: false,
};
const mockCostSummary: TypesGen.ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 2_500_000,
priced_message_count: 40,
unpriced_message_count: 2,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
by_model: [
{
model_config_id: "model-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 2_000_000,
message_count: 30,
total_input_tokens: 150_000,
total_output_tokens: 250_000,
total_cache_read_tokens: 8_000,
total_cache_creation_tokens: 4_000,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Refactor auth module",
total_cost_micros: 1_200_000,
message_count: 15,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 3_000,
total_cache_creation_tokens: 1_500,
},
],
};
const defaultDateRange = {
startDate: new Date("2026-02-10T00:00:00Z"),
endDate: new Date("2026-03-12T00:00:00Z"),
};
const baseProps = {
dateRange: defaultDateRange,
hasExplicitDateRange: false,
searchFilter: "",
page: 1,
pageSize: 25,
offset: 0,
isUsersLoading: false,
isUsersFetching: false,
usersError: undefined as unknown,
selectedUserId: null as string | null,
selectedUser: null as TypesGen.User | null,
isSelectedUserLoading: false,
isSelectedUserError: false,
selectedUserError: undefined as unknown,
summaryData: undefined as TypesGen.ChatCostSummary | undefined,
isSummaryLoading: false,
summaryError: undefined as unknown,
};
const meta = {
title: "pages/AgentsPage/AgentSettingsUsagePageView",
component: AgentSettingsUsagePageView,
args: {
...baseProps,
onDateRangeChange: fn(),
onSearchFilterChange: fn(),
onPageChange: fn(),
onUsersRetry: fn(),
onSelectedUserRetry: fn(),
onClearSelectedUser: fn(),
onSelectUser: fn(),
onSummaryRetry: fn(),
},
} satisfies Meta<typeof AgentSettingsUsagePageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsUsagePageView>;
export const UsageUserList: Story = {
args: {
usersData: mockUsersResponse,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("Bob Builder")).toBeInTheDocument();
await expect(
canvas.getByPlaceholderText("Search by name or username"),
).toBeInTheDocument();
},
};
export const UsageDateFilter: Story = {
args: {
usersData: mockUsersResponse,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
// The date range picker trigger should be visible.
const datePickerTrigger = await canvas.findByRole("button", {
name: /Feb.*Mar/,
});
expect(datePickerTrigger).toBeInTheDocument();
},
};
export const UsageDateFilterRefetchOverlay: Story = {
args: {
usersData: mockUsersResponse,
isUsersFetching: true,
isUsersLoading: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Table data should be visible behind the overlay.
await canvas.findByText("Alice Liddell");
// The refetch overlay spinner should be shown.
await expect(
await canvas.findByRole("status", { name: "Refreshing usage" }),
).toBeInTheDocument();
},
};
export const UsageEmpty: Story = {
args: {
usersData: {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Usage");
await expect(
await canvas.findByText("No usage data for this period."),
).toBeInTheDocument();
},
};
export const UsageUserDrillIn: Story = {
args: {
selectedUserId: "user-1",
selectedUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Detail view shows user info.
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("@alice")).toBeInTheDocument();
// The Back button should be visible.
await expect(canvas.getByText("Back")).toBeInTheDocument();
},
};
export const UsageUserDrillInAndBack: Story = {
args: {
selectedUserId: "user-1",
selectedUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
// Click Back.
await userEvent.click(canvas.getByText("Back"));
// The onClearSelectedUser callback should have been called.
expect(args.onClearSelectedUser).toHaveBeenCalled();
},
};
@@ -0,0 +1,408 @@
import dayjs from "dayjs";
import { ChevronLeftIcon } from "lucide-react";
import type { FC } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import { SearchField } from "#/components/SearchField/SearchField";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { formatTokenCount } from "#/utils/analytics";
import { formatCostMicros } from "#/utils/currency";
import { AdminBadge } from "./components/AdminBadge";
import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
import { SectionHeader } from "./components/SectionHeader";
const formatUsageDateRange = (
value: DateRangeValue,
options?: {
endDateIsExclusive?: boolean;
},
) => {
// Custom ranges keep the raw API end boundary, which can be midnight on
// the following day for full-day selections. Show the inclusive day in
// the drill-in label without changing the query params.
const displayEndDate =
options?.endDateIsExclusive &&
dayjs(value.endDate).isSame(dayjs(value.endDate).startOf("day"))
? dayjs(value.endDate).subtract(1, "day")
: dayjs(value.endDate);
return `${dayjs(value.startDate).format("MMM D")} ${displayEndDate.format(
"MMM D, YYYY",
)}`;
};
const UserRow: FC<{
user: TypesGen.ChatCostUserRollup;
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
}> = ({ user, onSelect }) => {
const clickableRowProps = useClickableTableRow({
onClick: () => onSelect(user),
});
return (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
>
<TableCell className="min-w-[220px] px-4 py-3">
<AvatarData
title={user.name || user.username}
subtitle={`@${user.username}`}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="px-4 py-3 text-right">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
</TableRow>
);
};
interface AgentSettingsUsagePageViewProps {
// Raw date range (parsed by Page from URL params)
dateRange: DateRangeValue;
hasExplicitDateRange: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
// Search & pagination (state owned by Page, needed for queries)
searchFilter: string;
onSearchFilterChange: (value: string) => void;
page: number;
onPageChange: (page: number) => void;
pageSize: number;
offset: number;
// User list query
usersData: TypesGen.ChatCostUsersResponse | undefined;
isUsersLoading: boolean;
isUsersFetching: boolean;
usersError: unknown;
onUsersRetry: () => void;
// Selected user drill-in
selectedUserId: string | null;
selectedUser: TypesGen.User | null;
isSelectedUserLoading: boolean;
isSelectedUserError: boolean;
selectedUserError: unknown;
onSelectedUserRetry: () => void;
onClearSelectedUser: () => void;
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
// Cost summary for selected user
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
export const AgentSettingsUsagePageView: FC<
AgentSettingsUsagePageViewProps
> = ({
dateRange,
hasExplicitDateRange,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
page,
onPageChange,
pageSize,
offset,
usersData,
isUsersLoading,
isUsersFetching,
usersError,
onUsersRetry,
selectedUserId,
selectedUser,
isSelectedUserLoading,
isSelectedUserError,
selectedUserError,
onSelectedUserRetry,
onClearSelectedUser,
onSelectUser,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
// ── Derived display state ──
const { endDate } = dateRange;
const isExclusiveMidnightEnd =
hasExplicitDateRange &&
endDate.getHours() === 0 &&
endDate.getMinutes() === 0 &&
endDate.getSeconds() === 0 &&
endDate.getMilliseconds() === 0;
const displayDateRange = isExclusiveMidnightEnd
? {
startDate: dateRange.startDate,
endDate: new Date(endDate.getTime() - 1),
}
: dateRange;
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive: hasExplicitDateRange,
});
const totalCount = usersData?.count ?? 0;
const hasPreviousPage = page > 1;
const hasNextPage = offset + pageSize < totalCount;
const header = (
<SectionHeader
label="Usage"
description={
selectedUserId
? "Review deployment chat usage for a specific user."
: "Review deployment chat usage and drill into individual users."
}
badge={<AdminBadge />}
action={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
);
if (selectedUserId) {
const backButton = (
<button
type="button"
onClick={onClearSelectedUser}
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
>
{" "}
<ChevronLeftIcon className="h-4 w-4" />
Back
</button>
);
if (isSelectedUserLoading) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] items-center justify-center">
<Spinner size="lg" loading className="text-content-secondary" />
</div>
</div>
);
}
if (isSelectedUserError || !selectedUser) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(
selectedUserError,
"Failed to load user profile.",
)}
</p>{" "}
<Button
variant="outline"
size="sm"
type="button"
onClick={onSelectedUserRetry}
>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border-default bg-surface-secondary px-4 py-3">
<AvatarData
title={selectedUser.name || selectedUser.username}
subtitle={`@${selectedUser.username}`}
src={selectedUser.avatar_url}
imgFallbackText={selectedUser.username}
/>
<div className="min-w-0 text-xs text-content-secondary">
<div>User ID: {selectedUser.id}</div>
<div>{dateRangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
summary={summaryData}
isLoading={isSummaryLoading}
error={summaryError}
onRetry={onSummaryRetry}
loadingLabel="Loading usage details"
emptyMessage="No usage data for this user in the selected period."
/>
</div>
);
}
return (
<div className="space-y-6">
{header}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={(value) => {
onSearchFilterChange(value);
onPageChange(1);
}}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
{usersData && (
<PaginationAmount
limit={pageSize}
totalRecords={usersData.count}
currentOffsetStart={usersData.count === 0 ? 0 : offset + 1}
paginationUnitLabel="users"
/>
)}
</div>
{isUsersLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersError != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(usersError, "Failed to load usage data.")}
</p>{" "}
<Button
variant="outline"
size="sm"
type="button"
onClick={onUsersRetry}
>
Retry
</Button>
</div>
)}
{usersData && (
<div className="relative">
{isUsersFetching && !isUsersLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersData.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<>
<div className="overflow-hidden rounded-lg border border-border-default">
<Table>
<TableHeader>
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
<TableHead className="px-4 py-3">User</TableHead>
<TableHead className="px-4 py-3 text-right">
Total Cost
</TableHead>
<TableHead className="px-4 py-3 text-right">
Messages
</TableHead>
<TableHead className="px-4 py-3 text-right">
Chats
</TableHead>
<TableHead className="px-4 py-3 text-right">
Input Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Output Tokens
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Read
</TableHead>
<TableHead className="px-4 py-3 text-right">
Cache Write
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersData.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
/>
))}
</TableBody>
</Table>
</div>
<PaginationWidgetBase
totalRecords={usersData.count}
currentPage={page}
pageSize={pageSize}
onPageChange={onPageChange}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</>
)}
</div>
)}
</div>
);
};
+3 -3
View File
@@ -35,13 +35,13 @@ import { useDashboard } from "#/modules/dashboard/useDashboard";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import { AgentsPageView } from "./AgentsPageView";
import { emptyInputStorageKey } from "./components/AgentCreateForm";
import { maybePlayChime } from "./components/AgentDetail/useAgentChime";
import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
import { useAgentsPWA } from "./hooks/useAgentsPWA";
import {
resolveArchiveAndDeleteAction,
shouldNavigateAfterArchive,
} from "./utils/agentWorkspaceUtils";
import { maybePlayChime } from "./utils/chime";
import { getModelOptionsFromConfigs } from "./utils/modelOptions";
import {
type ChatDetailError,
@@ -493,7 +493,7 @@ const AgentsPage: FC = () => {
// Only cancel a per-chat refetch when the cache
// already has data. Cancelling a first-time fetch
// reverts the query to pending/idle with no data
// and no retry, which AgentDetail shows as
// and no retry, which AgentChatPage shows as
// "Chat not found".
if (queryClient.getQueryData(chatKey(updatedChat.id))) {
void queryClient.cancelQueries({
@@ -568,7 +568,7 @@ const AgentsPage: FC = () => {
// Only create a new object if a field actually
// changed. Returning the same reference prevents
// react-query from notifying subscribers, avoiding
// unnecessary re-renders of AgentDetail during
// unnecessary re-renders of AgentChatPage during
// streaming when repeated status_change events
// carry the same "running" status.
const nextStatus = isStatusEvent
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import dayjs from "dayjs";
import { useState } from "react";
import { Navigate } from "react-router";
import {
expect,
fn,
@@ -14,8 +15,8 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { Chat } from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
MockNoPermissions,
MockPermissions,
@@ -27,8 +28,11 @@ import {
} from "#/testHelpers/storybook";
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import { AgentSettingsBehaviorPageView } from "./AgentSettingsBehaviorPageView";
import AgentSettingsPage from "./AgentSettingsPage";
import { AgentSettingsUsagePageView } from "./AgentSettingsUsagePageView";
import { AgentsPageView } from "./AgentsPageView";
import type { ModelSelectorOption } from "./components/ChatElements";
const defaultModelConfigID = "model-config-1";
@@ -139,12 +143,94 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
// across timezones.
const fixedNow = dayjs("2026-03-12T12:00:00");
// Renders the real PageView components with mock data so the
// visual snapshots match the actual UI.
const BehaviorRouteElement = () => {
const { permissions } = useAuthenticated();
return (
<AgentSettingsBehaviorPageView
canSetSystemPrompt={permissions.editDeploymentConfig}
systemPromptData={{
system_prompt: "",
include_default_system_prompt: true,
default_system_prompt: "You are Coder, an AI coding assistant...",
}}
userPromptData={{ custom_prompt: "" }}
desktopEnabledData={{ enable_desktop: false }}
workspaceTTLData={{ workspace_ttl_ms: 0 }}
isWorkspaceTTLLoading={false}
isWorkspaceTTLLoadError={false}
modelConfigsData={[]}
modelConfigsError={undefined}
isLoadingModelConfigs={false}
thresholds={[]}
isThresholdsLoading={false}
thresholdsError={undefined}
onSaveSystemPrompt={fn()}
isSavingSystemPrompt={false}
isSaveSystemPromptError={false}
onSaveUserPrompt={fn()}
isSavingUserPrompt={false}
isSaveUserPromptError={false}
onSaveDesktopEnabled={fn()}
isSavingDesktopEnabled={false}
isSaveDesktopEnabledError={false}
onSaveWorkspaceTTL={fn()}
isSavingWorkspaceTTL={false}
isSaveWorkspaceTTLError={false}
onSaveThreshold={fn(async () => undefined)}
onResetThreshold={fn(async () => undefined)}
/>
);
};
const UsageRouteElement = () => (
<AgentSettingsUsagePageView
dateRange={{
startDate: new Date("2026-02-10"),
endDate: new Date("2026-03-12"),
}}
hasExplicitDateRange={false}
onDateRangeChange={fn()}
searchFilter=""
onSearchFilterChange={fn()}
page={1}
onPageChange={fn()}
pageSize={25}
offset={0}
usersData={mockUsageUsers}
isUsersLoading={false}
isUsersFetching={false}
usersError={null}
onUsersRetry={fn()}
selectedUserId={null}
selectedUser={null}
isSelectedUserLoading={false}
isSelectedUserError={false}
selectedUserError={null}
onSelectedUserRetry={fn()}
onClearSelectedUser={fn()}
onSelectUser={fn()}
summaryData={undefined}
isSummaryLoading={false}
summaryError={null}
onSummaryRetry={fn()}
/>
);
const agentsRouting = {
path: "/agents",
useStoryElement: true,
children: [
{ path: "settings", element: <AgentSettingsPage /> },
{ path: "settings/:section", element: <AgentSettingsPage /> },
{
path: "settings",
element: <AgentSettingsPage />,
children: [
{ index: true, element: <Navigate to="behavior" replace /> },
{ path: "behavior", element: <BehaviorRouteElement /> },
{ path: "usage", element: <UsageRouteElement /> },
],
},
{ path: "analytics", element: <AgentAnalyticsPage now={fixedNow} /> },
{ path: ":agentId", element: <div /> },
{ index: true, element: <AgentCreatePage /> },
+2 -2
View File
@@ -1,9 +1,9 @@
import { type FC, type RefObject, useRef } from "react";
import { Outlet, useLocation } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import type { ModelSelectorOption } from "./components/ChatElements";
import {
AgentsSidebar,
sidebarViewFromPath,
@@ -30,7 +30,7 @@ export interface AgentsOutletContext {
onToggleSidebarCollapsed: () => void;
onExpandSidebar: () => void;
onChatReady: () => void;
/** Ref attached to the chat scroll container by AgentDetail. */
/** Ref attached to the chat scroll container by AgentChatPage. */
scrollContainerRef: RefObject<HTMLDivElement | null>;
}
@@ -0,0 +1,24 @@
import { ShieldIcon } from "lucide-react";
import type { FC } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
export const AdminBadge: FC = () => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex cursor-default items-center gap-1 rounded bg-surface-tertiary/60 px-2 py-1 text-[11px] leading-none font-medium text-content-secondary">
<ShieldIcon className="h-3 w-3" />
Admin
</span>
</TooltipTrigger>
<TooltipContent side="right">
Only visible to deployment administrators.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -1,10 +1,8 @@
import {
AlertTriangleIcon,
ArrowUpIcon,
Check,
CheckIcon,
ChevronRightIcon,
ClipboardPasteIcon,
ImageIcon,
MicIcon,
MonitorIcon,
@@ -25,10 +23,6 @@ import {
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import {
ModelSelector,
type ModelSelectorOption,
} from "#/components/ai-elements";
import { Button } from "#/components/Button/Button";
import {
ChatMessageInput,
@@ -52,44 +46,27 @@ import { Separator } from "#/components/Separator/Separator";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isMobileViewport } from "#/utils/mobile";
import { useOverflowCount } from "../hooks/useOverflowCount";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../utils/fetchTextAttachment";
import { formatProviderLabel } from "../utils/modelOptions";
import type { UploadState } from "./AttachmentPreview";
import { AttachmentPreview } from "./AttachmentPreview";
import { ModelSelector, type ModelSelectorOption } from "./ChatElements";
import type { AgentContextUsage } from "./ContextUsageIndicator";
import { ContextUsageIndicator } from "./ContextUsageIndicator";
import { ImageLightbox } from "./ImageLightbox";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
export type { ChatMessageInputRef } from "#/components/ChatMessageInput/ChatMessageInput";
export type UploadState = {
status: "uploading" | "uploaded" | "error";
fileId?: string;
error?: string;
};
export interface AgentContextUsage {
readonly usedTokens?: number;
readonly contextLimitTokens?: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly cacheReadTokens?: number;
readonly cacheCreationTokens?: number;
readonly reasoningTokens?: number;
// Percentage (0100) at which the context will be compacted.
readonly compressionThreshold?: number;
}
export {
ImageThumbnail,
type UploadState,
} from "./AttachmentPreview";
export type { AgentContextUsage } from "./ContextUsageIndicator";
interface AgentChatInputProps {
onSend: (message: string) => void;
@@ -156,326 +133,6 @@ interface AgentChatInputProps {
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
}
const hasFiniteTokenValue = (value: number | undefined): value is number =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
const formatTokenCount = (value: number | undefined): string =>
hasFiniteTokenValue(value) ? value.toLocaleString() : "--";
const formatTokenCountCompact = (value: number | undefined): string => {
if (!hasFiniteTokenValue(value)) {
return "--";
}
if (value >= 1_000_000) {
const m = value / 1_000_000;
return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, "")}M`;
}
if (value >= 1_000) {
const k = value / 1_000;
return `${Number.isInteger(k) ? k : k.toFixed(1).replace(/\.0$/, "")}K`;
}
return String(value);
};
const getIndicatorToneClassName = (percentUsed: number | null): string => {
if (percentUsed === null) {
return "text-content-secondary/60";
}
if (percentUsed >= 95) {
return "text-content-destructive";
}
if (percentUsed >= 85) {
return "text-content-warning";
}
return "text-content-secondary/60";
};
const RING_SIZE = 18;
const RING_STROKE = 2.5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
usage,
}) => {
const usedTokens = hasFiniteTokenValue(usage?.usedTokens)
? usage.usedTokens
: undefined;
const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens)
? usage.contextLimitTokens
: undefined;
const percentUsed =
usedTokens !== undefined &&
contextLimitTokens !== undefined &&
contextLimitTokens > 0
? (usedTokens / contextLimitTokens) * 100
: null;
const hasPercent = percentUsed !== null;
const percentLabel =
percentUsed === null ? "--" : `${Math.round(percentUsed)}%`;
const clampedPercent = hasPercent
? Math.min(Math.max(percentUsed, 0), 100)
: 100;
const dashOffset =
RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE;
const toneClassName = getIndicatorToneClassName(percentUsed);
const ariaLabel = hasPercent
? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.`
: "Context usage";
const triggerButton = (
<button
type="button"
aria-label={ariaLabel}
className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
>
<svg
className={cn("size-icon-sm -rotate-90", toneClassName)}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden
>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
className="stroke-content-secondary/25"
/>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="stroke-current transition-all duration-300 ease-out"
style={{
strokeDasharray: `${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`,
strokeDashoffset: dashOffset,
}}
/>
</svg>
</button>
);
const tooltipContent = (
<div className="text-xs text-content-primary">
{hasPercent
? `${percentLabel} ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used`
: "Context usage unavailable"}
{hasPercent &&
usage?.compressionThreshold !== undefined &&
usage.compressionThreshold > 0 && (
<div className="mt-1 text-content-secondary">
Compacts at {usage.compressionThreshold}%
</div>
)}
</div>
);
// On mobile viewports, Radix Tooltip only opens on hover which
// doesn't exist on touch devices. Use a Popover instead so a tap
// toggles the context-usage info.
if (isMobileViewport()) {
return (
<Popover>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PopoverContent side="top" className="w-auto px-3 py-2">
{tooltipContent}
</PopoverContent>
</Popover>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{triggerButton}</TooltipTrigger>
<TooltipContent side="top">{tooltipContent}</TooltipContent>
</Tooltip>
);
};
/** Renders an image thumbnail from a pre-created preview URL. */
export const ImageThumbnail: FC<{
previewUrl: string;
name: string;
className?: string;
}> = ({ previewUrl, name, className }) => (
<img
src={previewUrl}
alt={name}
className={cn(
"h-16 w-16 rounded-md border border-border-default object-cover",
className,
)}
/>
);
/** Renders a horizontal strip of attachment thumbnails above the input. */
export const AttachmentPreview: FC<{
attachments: readonly File[];
onRemove: (attachment: number | File) => void;
uploadStates?: Map<File, UploadState>;
previewUrls?: Map<File, string>;
onPreview?: (url: string) => void;
textContents?: Map<File, string>;
onTextPreview?: (content: string, fileName: string) => void;
onInlineText?: (file: File, content?: string) => void;
}> = ({
attachments,
onRemove,
uploadStates,
previewUrls,
onPreview,
textContents,
onTextPreview,
onInlineText,
}) => {
const textAttachmentLoadControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => textAttachmentLoadControllerRef.current?.abort();
}, []);
if (attachments.length === 0) return null;
const loadTextAttachmentContent = async (
content: string | undefined,
fileId: string | undefined,
): Promise<string | undefined> => {
textAttachmentLoadControllerRef.current?.abort();
if (content !== undefined || !fileId) {
textAttachmentLoadControllerRef.current = null;
return content;
}
const controller = new AbortController();
textAttachmentLoadControllerRef.current = controller;
try {
const fetchedContent = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
return fetchedContent;
} catch (err) {
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
if (err instanceof Error && err.name === "AbortError") {
return undefined;
}
console.error("Failed to load text attachment:", err);
return undefined;
}
};
return (
<div className="flex gap-2 overflow-x-auto border-b border-border-default/50 px-3 py-2">
{attachments.map((file, index) => {
const uploadState = uploadStates?.get(file);
const previewUrl = previewUrls?.get(file) ?? "";
const textContent = textContents?.get(file);
const textFileId =
uploadState?.status === "uploaded" ? uploadState.fileId : undefined;
const hasTextAttachment =
file.type === "text/plain" &&
(textContent !== undefined || textFileId !== undefined);
return (
<div
// Key combines file metadata with index as a fallback for
// duplicate names. Acceptable for a small, append-only list.
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="group relative"
>
{file.type.startsWith("image/") && previewUrl ? (
<button
type="button"
className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80"
onClick={() => onPreview?.(previewUrl)}
>
<ImageThumbnail previewUrl={previewUrl} name={file.name} />
</button>
) : hasTextAttachment ? (
<button
type="button"
aria-label="View text attachment"
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
if (nextContent !== undefined) {
onTextPreview?.(nextContent, file.name);
}
}}
>
<span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary">
{formatTextAttachmentPreview(textContent ?? "")}
</span>
</button>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary">
{file.name.split(".").pop()?.toUpperCase() || "FILE"}
</div>
)}
{hasTextAttachment && (
<button
type="button"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
onInlineText?.(file, nextContent);
}}
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label="Paste inline"
>
<ClipboardPasteIcon className="h-3.5 w-3.5" />
</button>
)}
{uploadState?.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay">
<Spinner className="h-5 w-5 text-white" loading />
</div>
)}
{uploadState?.status === "error" && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"
role="img"
aria-label="Upload error"
>
<AlertTriangleIcon className="h-5 w-5 text-content-warning" />
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-xs text-xs">
{uploadState.error ?? "Upload failed"}
</p>
</TooltipContent>
</Tooltip>
)}
<button
type="button"
onClick={() => onRemove(file)}
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label={`Remove ${file.name}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
};
type ToolBadgeData =
| { kind: "workspace"; name: string }
| { kind: "mcp"; server: TypesGen.MCPServerConfig };
@@ -5,19 +5,19 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { MockUserOwner } from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
} from "#/testHelpers/storybook";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import { createChatStore } from "./AgentDetail/ChatContext";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./AgentDetailView";
AgentChatPageLoadingView,
AgentChatPageNotFoundView,
AgentChatPageView,
} from "./AgentChatPageView";
import { createChatStore } from "./ChatConversation/chatStore";
import type { ModelSelectorOption } from "./ChatElements";
// ---------------------------------------------------------------------------
// Shared constants & helpers
@@ -55,7 +55,7 @@ const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
});
const buildEditing = (
overrides: Partial<ComponentProps<typeof AgentDetailView>["editing"]> = {},
overrides: Partial<ComponentProps<typeof AgentChatPageView>["editing"]> = {},
) => ({
chatInputRef: { current: null },
editorInitialValue: "",
@@ -72,7 +72,7 @@ const buildEditing = (
});
const buildGitWatcher = (): ComponentProps<
typeof AgentDetailView
typeof AgentChatPageView
>["gitWatcher"] => ({
repositories: new Map(),
refresh: fn(),
@@ -96,13 +96,13 @@ const agentsRouting = [
// story cares about.
// ---------------------------------------------------------------------------
type StoryProps = Omit<
Partial<ComponentProps<typeof AgentDetailView>>,
Partial<ComponentProps<typeof AgentChatPageView>>,
"editing"
> & {
editing?: Partial<ComponentProps<typeof AgentDetailView>["editing"]>;
editing?: Partial<ComponentProps<typeof AgentChatPageView>["editing"]>;
};
const StoryAgentDetailView: FC<StoryProps> = ({ editing, ...overrides }) => {
const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
const props = {
agentId: AGENT_ID,
chatTitle: "Help me refactor",
@@ -127,7 +127,7 @@ const StoryAgentDetailView: FC<StoryProps> = ({ editing, ...overrides }) => {
onSetShowSidebarPanel: fn(),
prNumber: undefined as number | undefined,
diffStatusData: undefined as ComponentProps<
typeof AgentDetailView
typeof AgentChatPageView
>["diffStatusData"],
gitWatcher: buildGitWatcher(),
canOpenEditors: false,
@@ -148,24 +148,24 @@ const StoryAgentDetailView: FC<StoryProps> = ({ editing, ...overrides }) => {
hasMoreMessages: false,
isFetchingMoreMessages: false,
onFetchMoreMessages: fn(),
mcpServers: [] as ComponentProps<typeof AgentDetailView>["mcpServers"],
mcpServers: [] as ComponentProps<typeof AgentChatPageView>["mcpServers"],
selectedMCPServerIds: [] as ComponentProps<
typeof AgentDetailView
typeof AgentChatPageView
>["selectedMCPServerIds"],
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
...overrides,
editing: buildEditing(editing),
};
return <AgentDetailView {...props} />;
return <AgentChatPageView {...props} />;
};
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailView> = {
title: "pages/AgentsPage/AgentDetailView",
component: AgentDetailView,
const meta: Meta<typeof AgentChatPageView> = {
title: "pages/AgentsPage/AgentChatPageView",
component: AgentChatPageView,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
@@ -181,26 +181,26 @@ const meta: Meta<typeof AgentDetailView> = {
};
export default meta;
type Story = StoryObj<typeof AgentDetailView>;
type Story = StoryObj<typeof AgentChatPageView>;
// ---------------------------------------------------------------------------
// AgentDetailView stories
// AgentChatPageView stories
// ---------------------------------------------------------------------------
/** Basic conversation view with a chat title, workspace, and no archive. */
export const Default: Story = {
render: () => <StoryAgentDetailView />,
render: () => <StoryAgentChatPageView />,
};
/** Archived agent displays the read-only banner below the top bar. */
export const Archived: Story = {
render: () => <StoryAgentDetailView isArchived isInputDisabled />,
render: () => <StoryAgentChatPageView isArchived isInputDisabled />,
};
/** Shows the parent chat link in the top bar when a parent exists. */
export const WithParentChat: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
parentChat={buildChat({ id: "parent-chat-1", title: "Root agent" })}
/>
),
@@ -209,7 +209,7 @@ export const WithParentChat: Story = {
/** Persisted error reason shown in the timeline area. */
export const WithError: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
persistedError={{
kind: "overloaded",
message: "Anthropic is currently overloaded.",
@@ -235,18 +235,18 @@ export const WithError: Story = {
/** Input area appears disabled when `isInputDisabled` is true. */
export const InputDisabled: Story = {
render: () => <StoryAgentDetailView isInputDisabled />,
render: () => <StoryAgentChatPageView isInputDisabled />,
};
/** Shows a sending/pending state for the input. */
export const SubmissionPending: Story = {
render: () => <StoryAgentDetailView isSubmissionPending />,
render: () => <StoryAgentChatPageView isSubmissionPending />,
};
/** Right sidebar panel is open with diff status data. */
export const WithSidebarPanel: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
showSidebarPanel
prNumber={123}
diffStatusData={
@@ -282,13 +282,13 @@ index abc1234..def5678 100644
/** Left sidebar is collapsed. */
export const SidebarCollapsed: Story = {
render: () => <StoryAgentDetailView isSidebarCollapsed />,
render: () => <StoryAgentChatPageView isSidebarCollapsed />,
};
/** No model options available — shows a disabled status message. */
export const NoModelOptions: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
hasModelOptions={false}
modelOptions={[]}
isInputDisabled
@@ -299,7 +299,7 @@ export const NoModelOptions: Story = {
/** Top bar has workspace action buttons visible. */
export const WithWorkspaceActions: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
canOpenEditors
canOpenWorkspace
sshCommand="ssh coder.workspace"
@@ -308,13 +308,13 @@ export const WithWorkspaceActions: Story = {
};
// ---------------------------------------------------------------------------
// AgentDetailLoadingView stories
// AgentChatPageLoadingView stories
// ---------------------------------------------------------------------------
/** Default loading state with skeleton placeholders. */
export const Loading: Story = {
render: () => (
<AgentDetailLoadingView
<AgentChatPageLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -332,7 +332,7 @@ export const Loading: Story = {
/** Loading state with the model selector populated. */
export const LoadingWithModelOptions: Story = {
render: () => (
<AgentDetailLoadingView
<AgentChatPageLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled={false}
effectiveSelectedModel={defaultModelConfigID}
@@ -349,7 +349,7 @@ export const LoadingWithModelOptions: Story = {
/** Loading state with the right panel pre-opened. */
export const LoadingWithRightPanel: Story = {
render: () => (
<AgentDetailLoadingView
<AgentChatPageLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -367,7 +367,7 @@ export const LoadingWithRightPanel: Story = {
/** Loading state with the left sidebar collapsed. */
export const LoadingSidebarCollapsed: Story = {
render: () => (
<AgentDetailLoadingView
<AgentChatPageLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled
effectiveSelectedModel={defaultModelConfigID}
@@ -429,7 +429,7 @@ const editingMessages = [
* banner + outline on the chat input. */
export const EditingMessage: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
store={buildStoreWithMessages(editingMessages)}
editing={{
editingMessageId: 3,
@@ -443,7 +443,7 @@ export const EditingMessage: Story = {
* indicator on the message being saved. */
export const EditingSaving: Story = {
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
store={buildStoreWithMessages(editingMessages)}
editing={{
editingMessageId: 3,
@@ -456,13 +456,13 @@ export const EditingSaving: Story = {
};
// ---------------------------------------------------------------------------
// AgentDetailNotFoundView stories
// AgentChatPageNotFoundView stories
// ---------------------------------------------------------------------------
/** Shows the "Chat not found" message. */
export const NotFound: Story = {
render: () => (
<AgentDetailNotFoundView
<AgentChatPageNotFoundView
titleElement={<title>Not Found Agents</title>}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
@@ -473,7 +473,7 @@ export const NotFound: Story = {
/** "Chat not found" with the left sidebar collapsed. */
export const NotFoundSidebarCollapsed: Story = {
render: () => (
<AgentDetailNotFoundView
<AgentChatPageNotFoundView
titleElement={<title>Not Found Agents</title>}
isSidebarCollapsed
onToggleSidebarCollapsed={fn()}
@@ -548,7 +548,7 @@ const getStoreMessages = (
export const ScrollToBottomButton: Story = {
decorators: scrollStoryDecorators,
render: () => (
<StoryAgentDetailView
<StoryAgentChatPageView
store={buildStoreWithMessages(buildLongConversation(40))}
/>
),
@@ -616,7 +616,7 @@ const preservedScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** When scrolled away from bottom, new content preserves scroll position. */
export const ScrollPositionPreservedOnNewContent: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={preservedScrollStore} />,
render: () => <StoryAgentChatPageView store={preservedScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
@@ -698,7 +698,7 @@ const pinnedScrollStore = buildStoreWithMessages(buildLongConversation(30));
/** When at bottom, new content keeps the user pinned to bottom. */
export const ScrollPinnedToBottomOnNewContent: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={pinnedScrollStore} />,
render: () => <StoryAgentChatPageView store={pinnedScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
@@ -773,7 +773,7 @@ const touchGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
* snap scroll to bottom. This prevents the mobile URL bar resize jump. */
export const ScrollNotJumpedDuringTouch: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={touchGuardScrollStore} />,
render: () => <StoryAgentChatPageView store={touchGuardScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
@@ -868,7 +868,7 @@ const wheelGuardScrollStore = buildStoreWithMessages(buildLongConversation(30));
* must not snap scroll to bottom. This prevents desktop scroll jump. */
export const ScrollNotJumpedDuringWheel: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={wheelGuardScrollStore} />,
render: () => <StoryAgentChatPageView store={wheelGuardScrollStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
@@ -957,7 +957,7 @@ const wheelDeferredStore = buildStoreWithMessages(buildLongConversation(30));
*/
export const ScrollRepinnedAfterWheelDeferredAppend: Story = {
decorators: scrollStoryDecorators,
render: () => <StoryAgentDetailView store={wheelDeferredStore} />,
render: () => <StoryAgentChatPageView store={wheelDeferredStore} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scrollContainer = canvas.getByTestId("scroll-container");
@@ -10,20 +10,20 @@ import {
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { DesktopPanelContext } from "#/components/ai-elements/tool/DesktopPanelContext";
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { pageTitle } from "#/utils/page";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import type { useChatStore } from "./AgentDetail/ChatContext";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetailContent";
import {
ChatConversationSkeleton,
RightPanelSkeleton,
} from "./AgentsSkeletons";
import type { useChatStore } from "./ChatConversation/chatStore";
import type { ModelSelectorOption } from "./ChatElements";
import { DesktopPanelContext } from "./ChatElements/tools/DesktopPanelContext";
import { ChatPageInput, ChatPageTimeline } from "./ChatPageContent";
import { ChatTopBar } from "./ChatTopBar";
import { GitPanel } from "./GitPanel/GitPanel";
import { RightPanel } from "./RightPanel/RightPanel";
import { SidebarTabView } from "./Sidebar/SidebarTabView";
@@ -54,7 +54,7 @@ interface EditingState {
handleContentChange: (content: string) => void;
}
interface AgentDetailViewProps {
interface AgentChatPageViewProps {
// Chat data.
agentId: string;
chatTitle: string | undefined;
@@ -142,7 +142,7 @@ interface AgentDetailViewProps {
desktopChatId?: string;
}
export const AgentDetailView: FC<AgentDetailViewProps> = ({
export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
agentId,
chatTitle,
parentChat,
@@ -247,7 +247,8 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
{" "}
<ChatTopBar
chatTitle={chatTitle}
parentChat={parentChat}
panel={{
@@ -303,7 +304,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onFetchMoreMessages={onFetchMoreMessages}
>
<div className="px-4">
<AgentDetailTimeline
<ChatPageTimeline
chatID={agentId}
store={store}
persistedError={persistedError}
@@ -316,7 +317,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
</div>
</ScrollAnchoredContainer>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
<ChatPageInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
@@ -396,7 +397,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
);
};
interface AgentDetailLoadingViewProps {
interface AgentChatPageLoadingViewProps {
titleElement: React.ReactNode;
isInputDisabled: boolean;
effectiveSelectedModel: string;
@@ -410,7 +411,7 @@ interface AgentDetailLoadingViewProps {
showRightPanel: boolean;
}
export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
titleElement,
isInputDisabled,
effectiveSelectedModel,
@@ -432,7 +433,7 @@ export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
>
{titleElement}
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<AgentDetailTopBar
<ChatTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
@@ -491,13 +492,13 @@ export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
);
};
interface AgentDetailNotFoundViewProps {
interface AgentChatPageNotFoundViewProps {
titleElement: React.ReactNode;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
export const AgentChatPageNotFoundView: FC<AgentChatPageNotFoundViewProps> = ({
titleElement,
isSidebarCollapsed,
onToggleSidebarCollapsed,
@@ -505,7 +506,7 @@ export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
<ChatTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { MockWorkspace } from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { AgentCreateForm } from "./AgentCreateForm";
@@ -29,13 +28,13 @@ const meta: Meta<typeof AgentCreateForm> = {
isModelCatalogLoading: false,
modelConfigs: [],
isModelConfigsLoading: false,
workspaceCount: 0,
workspaceOptions: [],
workspacesError: undefined,
isWorkspacesLoading: false,
},
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [],
count: 0,
});
},
};
@@ -71,12 +70,12 @@ const mockWorkspaces = [
];
export const WithWorkspaces: Story = {
args: {
workspaceOptions: mockWorkspaces,
workspaceCount: mockWorkspaces.length,
},
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -97,12 +96,12 @@ export const WithWorkspaces: Story = {
};
export const SearchWorkspaces: Story = {
args: {
workspaceOptions: mockWorkspaces,
workspaceCount: mockWorkspaces.length,
},
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -133,12 +132,12 @@ export const SearchWorkspaces: Story = {
};
export const SelectWorkspaceViaSearch: Story = {
args: {
workspaceOptions: mockWorkspaces,
workspaceCount: mockWorkspaces.length,
},
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: mockWorkspaces,
count: mockWorkspaces.length,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -210,10 +209,6 @@ export const PreservesAttachmentsOnFailedSend: Story = {
},
]),
);
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [],
count: 0,
});
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
@@ -1,13 +1,10 @@
import { type FC, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
import { toast } from "sonner";
import { isApiError } from "#/api/errors";
import { workspaces } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { Button } from "#/components/Button/Button";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { docs } from "#/utils/docs";
@@ -21,6 +18,7 @@ import {
isUsageLimitData,
} from "../utils/usageLimitMessage";
import { AgentChatInput } from "./AgentChatInput";
import type { ModelSelectorOption } from "./ChatElements";
import {
getDefaultMCPSelection,
getSavedMCPSelection,
@@ -104,6 +102,10 @@ interface AgentCreateFormProps {
isModelConfigsLoading: boolean;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onMCPAuthComplete?: (serverId: string) => void;
workspaceCount: number | undefined;
workspaceOptions: readonly TypesGen.Workspace[];
workspacesError: unknown;
isWorkspacesLoading: boolean;
}
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
@@ -117,6 +119,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
isModelConfigsLoading,
mcpServers,
onMCPAuthComplete,
workspaceCount: _workspaceCount,
workspaceOptions,
workspacesError,
isWorkspacesLoading,
}) => {
const { organizations } = useDashboard();
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
@@ -151,13 +157,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
return localStorage.getItem(selectedWorkspaceIdStorageKey) || null;
},
);
const workspaceOptions = workspacesQuery.data?.workspaces ?? [];
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
@@ -302,9 +306,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
<ErrorAlert error={createError} />
)
) : null}
{workspacesQuery.isError && (
<ErrorAlert error={workspacesQuery.error} />
)}
{workspacesError != null && <ErrorAlert error={workspacesError} />}
<AgentChatInput
onSend={handleSendWithAttachments}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
@@ -334,7 +336,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
workspaceOptions={workspaceOptions}
selectedWorkspaceId={selectedWorkspaceId}
onWorkspaceChange={handleWorkspaceChange}
isWorkspaceLoading={workspacesQuery.isLoading}
isWorkspaceLoading={isWorkspacesLoading}
/>
<p className="mt-1 text-center text-xs text-content-secondary/50">
Coder Agents is available via{" "}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AgentDetailSkeleton, AgentsPageSkeleton } from "./AgentsSkeletons";
import { AgentChatPageSkeleton, AgentsPageSkeleton } from "./AgentsSkeletons";
const meta: Meta<typeof AgentsPageSkeleton> = {
title: "pages/AgentsPage/AgentsSkeletons",
@@ -20,7 +20,7 @@ export const Page: Story = {};
export const Detail: Story = {
render: () => (
<div style={{ height: 600, width: "100%" }}>
<AgentDetailSkeleton />
<AgentChatPageSkeleton />
</div>
),
};
@@ -141,11 +141,11 @@ const ChatInputSkeleton: FC = () => (
);
/**
* Skeleton shown while the AgentDetail chunk is loading. Mimics a
* Skeleton shown while the AgentChatPage chunk is loading. Mimics a
* top bar + chat conversation layout so the user sees navigable
* structure during the brief Suspense fallback.
*/
export const AgentDetailSkeleton: FC = () => {
export const AgentChatPageSkeleton: FC = () => {
const rightPanel = getRightPanelState();
return (
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { AttachmentPreview, type UploadState } from "./AgentChatInput";
import { AttachmentPreview, type UploadState } from "./AttachmentPreview";
// Tiny 1x1 transparent PNG as data URI for previews.
const TINY_PNG =
@@ -0,0 +1,199 @@
import { AlertTriangleIcon, ClipboardPasteIcon, XIcon } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import {
fetchTextAttachmentContent,
formatTextAttachmentPreview,
} from "../utils/fetchTextAttachment";
export type UploadState = {
status: "uploading" | "uploaded" | "error";
fileId?: string;
error?: string;
};
/** Renders an image thumbnail from a pre-created preview URL. */
export const ImageThumbnail: FC<{
previewUrl: string;
name: string;
className?: string;
}> = ({ previewUrl, name, className }) => (
<img
src={previewUrl}
alt={name}
className={cn(
"h-16 w-16 rounded-md border border-border-default object-cover",
className,
)}
/>
);
/** Renders a horizontal strip of attachment thumbnails above the input. */
export const AttachmentPreview: FC<{
attachments: readonly File[];
onRemove: (attachment: number | File) => void;
uploadStates?: Map<File, UploadState>;
previewUrls?: Map<File, string>;
onPreview?: (url: string) => void;
textContents?: Map<File, string>;
onTextPreview?: (content: string, fileName: string) => void;
onInlineText?: (file: File, content?: string) => void;
}> = ({
attachments,
onRemove,
uploadStates,
previewUrls,
onPreview,
textContents,
onTextPreview,
onInlineText,
}) => {
const textAttachmentLoadControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => textAttachmentLoadControllerRef.current?.abort();
}, []);
if (attachments.length === 0) return null;
const loadTextAttachmentContent = async (
content: string | undefined,
fileId: string | undefined,
): Promise<string | undefined> => {
textAttachmentLoadControllerRef.current?.abort();
if (content !== undefined || !fileId) {
textAttachmentLoadControllerRef.current = null;
return content;
}
const controller = new AbortController();
textAttachmentLoadControllerRef.current = controller;
try {
const fetchedContent = await fetchTextAttachmentContent(
fileId,
controller.signal,
);
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
return fetchedContent;
} catch (err) {
if (textAttachmentLoadControllerRef.current === controller) {
textAttachmentLoadControllerRef.current = null;
}
if (err instanceof Error && err.name === "AbortError") {
return undefined;
}
console.error("Failed to load text attachment:", err);
return undefined;
}
};
return (
<div className="flex gap-2 overflow-x-auto border-b border-border-default/50 px-3 py-2">
{attachments.map((file, index) => {
const uploadState = uploadStates?.get(file);
const previewUrl = previewUrls?.get(file) ?? "";
const textContent = textContents?.get(file);
const textFileId =
uploadState?.status === "uploaded" ? uploadState.fileId : undefined;
const hasTextAttachment =
file.type === "text/plain" &&
(textContent !== undefined || textFileId !== undefined);
return (
<div
// Key combines file metadata with index as a fallback for
// duplicate names. Acceptable for a small, append-only list.
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="group relative"
>
{file.type.startsWith("image/") && previewUrl ? (
<button
type="button"
className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80"
onClick={() => onPreview?.(previewUrl)}
>
<ImageThumbnail previewUrl={previewUrl} name={file.name} />
</button>
) : hasTextAttachment ? (
<button
type="button"
aria-label="View text attachment"
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
if (nextContent !== undefined) {
onTextPreview?.(nextContent, file.name);
}
}}
>
<span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary">
{formatTextAttachmentPreview(textContent ?? "")}
</span>
</button>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary">
{file.name.split(".").pop()?.toUpperCase() || "FILE"}
</div>
)}
{hasTextAttachment && (
<button
type="button"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
onInlineText?.(file, nextContent);
}}
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label="Paste inline"
>
<ClipboardPasteIcon className="h-3.5 w-3.5" />
</button>
)}
{uploadState?.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay">
<Spinner className="h-5 w-5 text-white" loading />
</div>
)}
{uploadState?.status === "error" && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"
role="img"
aria-label="Upload error"
>
<AlertTriangleIcon className="h-5 w-5 text-content-warning" />
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-xs text-xs">
{uploadState.error ?? "Upload failed"}
</p>
</TooltipContent>
</Tooltip>
)}
<button
type="button"
onClick={() => onRemove(file)}
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label={`Remove ${file.name}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
};
@@ -1,9 +1,9 @@
import { ExternalLinkIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Response, Shimmer } from "#/components/ai-elements";
import { Button } from "#/components/Button/Button";
import { Pill } from "#/components/Pill/Pill";
import { Response, Shimmer } from "../ChatElements";
import { getKindLabel, getProviderStatusURL } from "./chatStatusHelpers";
import type { LiveStatusModel } from "./liveStatusModel";
@@ -67,7 +67,7 @@ const defaultArgs: Omit<
};
const meta: Meta<typeof ConversationTimeline> = {
title: "pages/AgentsPage/AgentDetail/ConversationTimeline",
title: "pages/AgentsPage/ChatConversation/ConversationTimeline",
component: ConversationTimeline,
decorators: [
(Story) => (
@@ -10,15 +10,6 @@ import {
} from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import {
ConversationItem,
Message,
MessageContent,
Response,
Shimmer,
Tool,
} from "#/components/ai-elements";
import { WebSearchSources } from "#/components/ai-elements/tool";
import { FileReferenceChip } from "#/components/ChatMessageInput/FileReferenceNode";
import { Spinner } from "#/components/Spinner/Spinner";
import {
@@ -33,10 +24,17 @@ import {
formatTextAttachmentPreview,
} from "../../utils/fetchTextAttachment";
import { ImageThumbnail } from "../AgentChatInput";
import {
ConversationItem,
Message,
MessageContent,
Response,
Shimmer,
Tool,
} from "../ChatElements";
import { WebSearchSources } from "../ChatElements/tools";
import { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import { ChatStatusCallout } from "./ChatStatusCallout";
import type { LiveStatusModel } from "./liveStatusModel";
import { getEditableUserMessagePayload } from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
import type {
@@ -44,7 +42,6 @@ import type {
ParsedMessageContent,
ParsedMessageEntry,
RenderBlock,
StreamState,
} from "./types";
const ReasoningDisclosure = memo<{
@@ -243,7 +240,7 @@ const FileBlock: FC<{
// response / thinking / tool / file / sources switch so both
// consumers stay in sync. PascalCase so the React Compiler
// auto-memoizes every element inside.
const BlockList: FC<{
export const BlockList: FC<{
blocks: readonly RenderBlock[];
tools: readonly MergedTool[];
keyPrefix: string;
@@ -654,78 +651,6 @@ const ChatMessageItem = memo<{
},
);
const hasTransientLiveStatus = (liveStatus: LiveStatusModel): boolean =>
liveStatus.phase === "starting" ||
liveStatus.phase === "retrying" ||
liveStatus.phase === "reconnecting";
export const StreamingOutput: FC<{
streamState: StreamState | null;
streamTools: readonly MergedTool[];
subagentTitles?: Map<string, string>;
computerUseSubagentIds?: Set<string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
liveStatus: LiveStatusModel;
startingResetKey?: string;
urlTransform?: UrlTransform;
mcpServers?: readonly TypesGen.MCPServerConfig[];
}> = ({
streamState,
streamTools,
subagentTitles,
computerUseSubagentIds,
subagentStatusOverrides,
liveStatus,
startingResetKey,
urlTransform,
mcpServers,
}) => {
if (liveStatus.phase === "idle") {
return null;
}
const isStreaming = liveStatus.phase === "streaming";
const shouldShowBlocks =
liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput;
const shouldShowStatusCallout = hasTransientLiveStatus(liveStatus);
if (!shouldShowBlocks && !shouldShowStatusCallout) {
return null;
}
const conversationItemProps = { role: "assistant" as const };
const blocks = shouldShowBlocks ? (streamState?.blocks ?? []) : [];
return (
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{shouldShowBlocks && (
<BlockList
blocks={blocks}
tools={streamTools}
keyPrefix="stream"
isStreaming={isStreaming}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
subagentStatusOverrides={subagentStatusOverrides}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
)}
{shouldShowStatusCallout && (
<ChatStatusCallout
status={liveStatus}
startingResetKey={startingResetKey}
/>
)}
</div>
</MessageContent>
</Message>
</ConversationItem>
);
};
const StickyUserMessage = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
@@ -21,7 +21,7 @@ const defaultArgs: React.ComponentProps<typeof LiveStreamTailContent> = {
};
const meta: Meta<typeof LiveStreamTailContent> = {
title: "pages/AgentsPage/AgentDetail/LiveStreamTail",
title: "pages/AgentsPage/ChatConversation/LiveStreamTail",
component: LiveStreamTailContent,
decorators: [
(Story) => (
@@ -4,6 +4,7 @@ import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { ChatStatusCallout } from "./ChatStatusCallout";
import {
selectIsAwaitingFirstStreamChunk,
selectReconnectState,
@@ -13,10 +14,9 @@ import {
selectSubagentStatusOverrides,
useChatSelector,
type useChatStore,
} from "./ChatContext";
import { ChatStatusCallout } from "./ChatStatusCallout";
import { StreamingOutput } from "./ConversationTimeline";
} from "./chatStore";
import { deriveLiveStatus, type LiveStatusModel } from "./liveStatusModel";
import { StreamingOutput } from "./StreamingOutput";
import { buildStreamTools } from "./streamState";
import type { MergedTool, StreamState } from "./types";
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, waitFor, within } from "storybook/test";
import { StreamingOutput } from "./ConversationTimeline";
import { StreamingOutput } from "./StreamingOutput";
import {
buildLiveStatus,
buildReconnectState,
@@ -12,7 +12,7 @@ import {
// chain, but it's self-contained enough to render standalone.
const meta: Meta<typeof StreamingOutput> = {
title: "pages/AgentsPage/AgentDetail/StreamingOutput",
title: "pages/AgentsPage/ChatConversation/StreamingOutput",
component: StreamingOutput,
decorators: [
(Story) => (
@@ -0,0 +1,80 @@
import type { FC } from "react";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
import { ConversationItem, Message, MessageContent } from "../ChatElements";
import { ChatStatusCallout } from "./ChatStatusCallout";
import { BlockList } from "./ConversationTimeline";
import type { LiveStatusModel } from "./liveStatusModel";
import type { MergedTool, StreamState } from "./types";
const hasTransientLiveStatus = (liveStatus: LiveStatusModel): boolean =>
liveStatus.phase === "starting" ||
liveStatus.phase === "retrying" ||
liveStatus.phase === "reconnecting";
export const StreamingOutput: FC<{
streamState: StreamState | null;
streamTools: readonly MergedTool[];
subagentTitles?: Map<string, string>;
computerUseSubagentIds?: Set<string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
liveStatus: LiveStatusModel;
startingResetKey?: string;
urlTransform?: UrlTransform;
mcpServers?: readonly TypesGen.MCPServerConfig[];
}> = ({
streamState,
streamTools,
subagentTitles,
computerUseSubagentIds,
subagentStatusOverrides,
liveStatus,
startingResetKey,
urlTransform,
mcpServers,
}) => {
if (liveStatus.phase === "idle") {
return null;
}
const isStreaming = liveStatus.phase === "streaming";
const shouldShowBlocks =
liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput;
const shouldShowStatusCallout = hasTransientLiveStatus(liveStatus);
if (!shouldShowBlocks && !shouldShowStatusCallout) {
return null;
}
const conversationItemProps = { role: "assistant" as const };
const blocks = shouldShowBlocks ? (streamState?.blocks ?? []) : [];
return (
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{shouldShowBlocks && (
<BlockList
blocks={blocks}
tools={streamTools}
keyPrefix="stream"
isStreaming={isStreaming}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
subagentStatusOverrides={subagentStatusOverrides}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
)}
{shouldShowStatusCallout && (
<ChatStatusCallout
status={liveStatus}
startingResetKey={startingResetKey}
/>
)}
</div>
</MessageContent>
</Message>
</ConversationItem>
);
};
@@ -1,4 +1,4 @@
import { asString } from "#/components/ai-elements/runtimeTypeUtils";
import { asString } from "../ChatElements/runtimeTypeUtils";
import type { RenderBlock } from "./types";
export const asNonEmptyString = (value: unknown): string | undefined => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import type { ModelSelectorOption } from "../ChatElements";
import {
extractContextUsageFromMessage,
getLatestContextUsage,
@@ -1,7 +1,7 @@
import type * as TypesGen from "#/api/typesGenerated";
import type { ModelSelectorOption } from "#/components/ai-elements";
import { asString } from "#/components/ai-elements/runtimeTypeUtils";
import type { AgentContextUsage } from "../AgentChatInput";
import type { ModelSelectorOption } from "../ChatElements";
import { asString } from "../ChatElements/runtimeTypeUtils";
import { asNonEmptyString } from "./blockUtils";
export const extractContextUsageFromMessage = (
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import { createChatStore } from "./ChatContext";
import { createChatStore } from "./chatStore";
// ---------------------------------------------------------------------------
// Helpers
@@ -46,7 +46,7 @@ import {
selectSubagentStatusOverrides,
useChatSelector,
useChatStore,
} from "./ChatContext";
} from "./chatStore";
vi.mock("#/api/api", () => ({
watchChat: vi.fn(),
@@ -0,0 +1,569 @@
import { useSyncExternalStore } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import {
type ChatDetailError,
chatDetailErrorsEqual,
} from "../../utils/usageLimitMessage";
import { applyMessagePartToStreamState } from "./streamState";
import type { ReconnectState, RetryState, StreamState } from "./types";
const byMessageCreatedAt = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): number => {
return (
new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
);
};
const buildMessageMap = (
messages: readonly TypesGen.ChatMessage[],
): Map<number, TypesGen.ChatMessage> =>
new Map(messages.map((message) => [message.id, message]));
const buildOrderedMessageIDs = (
messages: readonly TypesGen.ChatMessage[],
): readonly number[] => {
const sorted = [...messages];
sorted.sort(byMessageCreatedAt);
return sorted.map((message) => message.id);
};
const mapsEqualByRef = <K, V>(left: Map<K, V>, right: Map<K, V>): boolean => {
if (left.size !== right.size) {
return false;
}
for (const [key, value] of left) {
if (!right.has(key) || right.get(key) !== value) {
return false;
}
}
return true;
};
const arraysEqual = <T>(left: readonly T[], right: readonly T[]): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
};
const jsonValuesEqual = (left: unknown, right: unknown): boolean => {
if (left === right) {
return true;
}
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
};
const chatMessagesEqualByValue = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): boolean =>
left.id === right.id &&
left.chat_id === right.chat_id &&
left.model_config_id === right.model_config_id &&
left.created_at === right.created_at &&
left.role === right.role &&
jsonValuesEqual(left.content, right.content) &&
jsonValuesEqual(left.usage, right.usage);
export const chatQueuedMessagesEqualByID = (
left: readonly TypesGen.ChatQueuedMessage[],
right: readonly TypesGen.ChatQueuedMessage[],
): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.id !== right[index]?.id) {
return false;
}
}
return true;
};
const retryStatesEqual = (
left: RetryState | null,
right: RetryState | null,
): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.attempt === right.attempt &&
left.error === right.error &&
left.kind === right.kind &&
left.provider === right.provider &&
left.delayMs === right.delayMs &&
left.retryingAt === right.retryingAt
);
};
const reconnectStatesEqual = (
left: ReconnectState | null,
right: ReconnectState | null,
): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.attempt === right.attempt &&
left.delayMs === right.delayMs &&
left.retryingAt === right.retryingAt
);
};
export const isActiveChatStatus = (
status: TypesGen.ChatStatus | null,
): boolean => status === "running" || status === "pending";
export type ChatStoreState = {
messagesByID: Map<number, TypesGen.ChatMessage>;
orderedMessageIDs: readonly number[];
streamState: StreamState | null;
chatStatus: TypesGen.ChatStatus | null;
streamError: ChatDetailError | null;
retryState: RetryState | null;
reconnectState: ReconnectState | null;
queuedMessages: readonly TypesGen.ChatQueuedMessage[];
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
};
export type ChatStore = {
getSnapshot: () => ChatStoreState;
subscribe: (listener: () => void) => () => void;
batch: (fn: () => void) => void;
replaceMessages: (
messages: readonly TypesGen.ChatMessage[] | undefined,
) => void;
upsertDurableMessage: (message: TypesGen.ChatMessage) => {
isDuplicate: boolean;
changed: boolean;
};
upsertDurableMessages: (messages: readonly TypesGen.ChatMessage[]) => void;
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
setQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
setChatStatus: (status: TypesGen.ChatStatus | null) => void;
setStreamError: (reason: ChatDetailError | null) => void;
clearStreamError: () => void;
setRetryState: (state: RetryState | null) => void;
clearRetryState: () => void;
setReconnectState: (state: ReconnectState | null) => void;
clearReconnectState: () => void;
clearStreamState: () => void;
resetTransportReplayState: () => void;
setSubagentStatusOverride: (
chatID: string,
status: TypesGen.ChatStatus,
) => void;
resetTransientState: () => void;
};
const createInitialState = (): ChatStoreState => ({
messagesByID: new Map(),
orderedMessageIDs: [],
streamState: null,
chatStatus: null,
streamError: null,
retryState: null,
reconnectState: null,
queuedMessages: [],
subagentStatusOverrides: new Map(),
});
export const createChatStore = (): ChatStore => {
let state = createInitialState();
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) {
listener();
}
};
// Batching: suppress emit() during a batch and fire once
// at the end. This collapses N store mutations from a
// single WebSocket message into one subscriber notification.
let batchDepth = 0;
let batchDirty = false;
const batch = (fn: () => void): void => {
batchDepth += 1;
try {
fn();
} finally {
batchDepth -= 1;
if (batchDepth === 0 && batchDirty) {
batchDirty = false;
emit();
}
}
};
const setState = (
updater: (current: ChatStoreState) => ChatStoreState,
): void => {
const next = updater(state);
if (next === state) {
return;
}
state = next;
if (batchDepth > 0) {
batchDirty = true;
} else {
emit();
}
};
const replaceMessages = (
messages: readonly TypesGen.ChatMessage[] | undefined,
): void => {
const safeMessages = messages ?? [];
const nextMessagesByID = buildMessageMap(safeMessages);
const nextOrderedMessageIDs = buildOrderedMessageIDs(safeMessages);
// Fast-path: skip setState entirely when nothing changed.
if (
mapsEqualByRef(state.messagesByID, nextMessagesByID) &&
arraysEqual(state.orderedMessageIDs, nextOrderedMessageIDs)
) {
return;
}
setState((current) => {
// Re-check equality against `current` inside the updater
// to avoid overwriting a concurrent state change.
if (
mapsEqualByRef(current.messagesByID, nextMessagesByID) &&
arraysEqual(current.orderedMessageIDs, nextOrderedMessageIDs)
) {
return current;
}
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
};
const upsertDurableMessage = (message: TypesGen.ChatMessage) => {
// Use `state` for the early-return guard so we can return
// the result synchronously. The actual mutation below uses
// `current` inside the updater to avoid overwriting a
// concurrent state change (TOCTOU).
const existing = state.messagesByID.get(message.id);
const isDuplicate = state.messagesByID.has(message.id);
if (existing && chatMessagesEqualByValue(existing, message)) {
return { isDuplicate, changed: false };
}
let actuallyChanged = false;
setState((current) => {
// Re-check inside the updater: another call may have
// already applied this exact message.
const curExisting = current.messagesByID.get(message.id);
if (curExisting && chatMessagesEqualByValue(curExisting, message)) {
return current;
}
actuallyChanged = true;
const nextMessagesByID = new Map(current.messagesByID);
nextMessagesByID.set(message.id, message);
const curIsDuplicate = current.messagesByID.has(message.id);
const needsReorder =
!curIsDuplicate || nextMessagesByID.size !== current.messagesByID.size;
const nextOrderedMessageIDs = needsReorder
? buildOrderedMessageIDs(Array.from(nextMessagesByID.values()))
: current.orderedMessageIDs;
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
return { isDuplicate, changed: actuallyChanged };
};
// Bulk variant that applies all messages in a single pass —
// one Map copy and one sort instead of N copies and N sorts.
const upsertDurableMessages = (
messages: readonly TypesGen.ChatMessage[],
): void => {
if (messages.length === 0) {
return;
}
setState((current) => {
let nextMessagesByID: Map<number, TypesGen.ChatMessage> | null = null;
for (const message of messages) {
const map = nextMessagesByID ?? current.messagesByID;
const existing = map.get(message.id);
if (existing && chatMessagesEqualByValue(existing, message)) {
continue;
}
// Lazily copy the map on first actual change.
if (!nextMessagesByID) {
nextMessagesByID = new Map(current.messagesByID);
}
nextMessagesByID.set(message.id, message);
}
if (!nextMessagesByID) {
return current;
}
const needsReorder = nextMessagesByID.size !== current.messagesByID.size;
const nextOrderedMessageIDs = needsReorder
? buildOrderedMessageIDs(Array.from(nextMessagesByID.values()))
: current.orderedMessageIDs;
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
};
const applyMessageParts = (parts: readonly TypesGen.ChatMessagePart[]) => {
if (parts.length === 0) {
return;
}
setState((current) => {
let nextStreamState: StreamState | null = current.streamState;
for (const part of parts) {
nextStreamState = applyMessagePartToStreamState(nextStreamState, part);
}
if (nextStreamState === current.streamState) {
return current;
}
return {
...current,
streamState: nextStreamState,
};
});
};
return {
getSnapshot: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
batch,
replaceMessages,
upsertDurableMessage,
upsertDurableMessages,
applyMessagePart: (part) => applyMessageParts([part]),
applyMessageParts,
setQueuedMessages: (queuedMessages) => {
const nextQueuedMessages = queuedMessages ?? [];
setState((current) => {
if (
chatQueuedMessagesEqualByID(
current.queuedMessages,
nextQueuedMessages,
)
) {
return current;
}
return { ...current, queuedMessages: nextQueuedMessages };
});
},
setChatStatus: (status) => {
if (state.chatStatus === status) {
return;
}
setState((current) => ({
...current,
chatStatus: status,
}));
},
setStreamError: (reason) => {
setState((current) => {
if (chatDetailErrorsEqual(current.streamError, reason)) {
return current;
}
return {
...current,
streamError: reason,
};
});
},
clearStreamError: () => {
if (state.streamError === null) {
return;
}
setState((current) => ({
...current,
streamError: null,
}));
},
setRetryState: (retryState) => {
setState((current) => {
if (retryStatesEqual(current.retryState, retryState)) {
return current;
}
return {
...current,
retryState,
};
});
},
clearRetryState: () => {
if (state.retryState === null) {
return;
}
setState((current) => ({
...current,
retryState: null,
}));
},
setReconnectState: (reconnectState) => {
setState((current) => {
if (reconnectStatesEqual(current.reconnectState, reconnectState)) {
return current;
}
return {
...current,
reconnectState,
};
});
},
clearReconnectState: () => {
if (state.reconnectState === null) {
return;
}
setState((current) => ({
...current,
reconnectState: null,
}));
},
clearStreamState: () => {
if (state.streamState === null) {
return;
}
setState((current) => ({
...current,
streamState: null,
}));
},
resetTransportReplayState: () => {
if (
state.reconnectState === null &&
state.streamState === null &&
state.streamError === null
) {
return;
}
setState((current) => ({
...current,
reconnectState: null,
streamState: null,
streamError: null,
}));
},
setSubagentStatusOverride: (chatID, status) => {
if (state.subagentStatusOverrides.get(chatID) === status) {
return;
}
setState((current) => {
if (current.subagentStatusOverrides.get(chatID) === status) {
return current;
}
const nextOverrides = new Map(current.subagentStatusOverrides);
nextOverrides.set(chatID, status);
return { ...current, subagentStatusOverrides: nextOverrides };
});
},
resetTransientState: () => {
if (
state.streamState === null &&
state.streamError === null &&
state.retryState === null &&
state.reconnectState === null &&
state.subagentStatusOverrides.size === 0
) {
return;
}
setState((current) => ({
...current,
streamState: null,
streamError: null,
retryState: null,
reconnectState: null,
subagentStatusOverrides: new Map(),
}));
},
};
};
export const selectMessagesByID = (state: ChatStoreState) => state.messagesByID;
export const selectOrderedMessageIDs = (state: ChatStoreState) =>
state.orderedMessageIDs;
export const selectStreamState = (state: ChatStoreState) => state.streamState;
export const selectHasStreamState = (state: ChatStoreState) =>
state.streamState !== null;
export const selectChatStatus = (state: ChatStoreState) => state.chatStatus;
export const selectStreamError = (state: ChatStoreState) => state.streamError;
export const selectQueuedMessages = (state: ChatStoreState) =>
state.queuedMessages;
export const selectSubagentStatusOverrides = (state: ChatStoreState) =>
state.subagentStatusOverrides;
export const selectRetryState = (state: ChatStoreState) => state.retryState;
export const selectReconnectState = (state: ChatStoreState) =>
state.reconnectState;
const selectLatestDurableMessage = (
state: ChatStoreState,
): TypesGen.ChatMessage | undefined => {
const latestMessageID =
state.orderedMessageIDs[state.orderedMessageIDs.length - 1];
return latestMessageID === undefined
? undefined
: state.messagesByID.get(latestMessageID);
};
export const selectIsAwaitingFirstStreamChunk = (
state: ChatStoreState,
): boolean => {
const latestMessage = selectLatestDurableMessage(state);
const latestMessageNeedsAssistantResponse =
!latestMessage || latestMessage.role !== "assistant";
return (
state.streamState === null &&
isActiveChatStatus(state.chatStatus) &&
latestMessageNeedsAssistantResponse
);
};
export const useChatSelector = <T>(
store: ChatStore,
selector: (state: ChatStoreState) => T,
): T => {
const getSnapshot = () => selector(store.getSnapshot());
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
export { useChatStore } from "./useChatStore";
@@ -1,5 +1,5 @@
import type * as TypesGen from "#/api/typesGenerated";
import { asRecord, asString } from "#/components/ai-elements/runtimeTypeUtils";
import { asRecord, asString } from "../ChatElements/runtimeTypeUtils";
import { appendTextBlock } from "./blockUtils";
import type {
MergedTool,
@@ -1,18 +1,21 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState } from "react";
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 { asNumber, asString } from "#/components/ai-elements/runtimeTypeUtils";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { asNumber, asString } from "../ChatElements/runtimeTypeUtils";
import {
type ChatDetailError,
chatDetailErrorsEqual,
} from "../../utils/usageLimitMessage";
import { applyMessagePartToStreamState } from "./streamState";
import type { ReconnectState, RetryState, StreamState } from "./types";
type ChatStore,
type ChatStoreState,
chatQueuedMessagesEqualByID,
createChatStore,
isActiveChatStatus,
} from "./chatStore";
import type { RetryState } from "./types";
const isChatStreamEvent = (data: unknown): data is TypesGen.ChatStreamEvent =>
typeof data === "object" &&
@@ -59,523 +62,12 @@ const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => {
};
};
const byMessageCreatedAt = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): number => {
return (
new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
);
};
const buildMessageMap = (
messages: readonly TypesGen.ChatMessage[],
): Map<number, TypesGen.ChatMessage> =>
new Map(messages.map((message) => [message.id, message]));
const buildOrderedMessageIDs = (
messages: readonly TypesGen.ChatMessage[],
): readonly number[] => {
const sorted = [...messages];
sorted.sort(byMessageCreatedAt);
return sorted.map((message) => message.id);
};
const mapsEqualByRef = <K, V>(left: Map<K, V>, right: Map<K, V>): boolean => {
if (left.size !== right.size) {
return false;
}
for (const [key, value] of left) {
if (!right.has(key) || right.get(key) !== value) {
return false;
}
}
return true;
};
const arraysEqual = <T>(left: readonly T[], right: readonly T[]): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
};
const jsonValuesEqual = (left: unknown, right: unknown): boolean => {
if (left === right) {
return true;
}
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
};
const chatMessagesEqualByValue = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): boolean =>
left.id === right.id &&
left.chat_id === right.chat_id &&
left.model_config_id === right.model_config_id &&
left.created_at === right.created_at &&
left.role === right.role &&
jsonValuesEqual(left.content, right.content) &&
jsonValuesEqual(left.usage, right.usage);
const chatQueuedMessagesEqualByID = (
left: readonly TypesGen.ChatQueuedMessage[],
right: readonly TypesGen.ChatQueuedMessage[],
): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.id !== right[index]?.id) {
return false;
}
}
return true;
};
const retryStatesEqual = (
left: RetryState | null,
right: RetryState | null,
): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.attempt === right.attempt &&
left.error === right.error &&
left.kind === right.kind &&
left.provider === right.provider &&
left.delayMs === right.delayMs &&
left.retryingAt === right.retryingAt
);
};
const reconnectStatesEqual = (
left: ReconnectState | null,
right: ReconnectState | null,
): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.attempt === right.attempt &&
left.delayMs === right.delayMs &&
left.retryingAt === right.retryingAt
);
};
const isActiveChatStatus = (status: TypesGen.ChatStatus | null): boolean =>
status === "running" || status === "pending";
const shouldSurfaceReconnectState = (state: ChatStoreState): boolean =>
state.streamError === null &&
(state.streamState !== null ||
state.retryState !== null ||
isActiveChatStatus(state.chatStatus));
type ChatStoreState = {
messagesByID: Map<number, TypesGen.ChatMessage>;
orderedMessageIDs: readonly number[];
streamState: StreamState | null;
chatStatus: TypesGen.ChatStatus | null;
streamError: ChatDetailError | null;
retryState: RetryState | null;
reconnectState: ReconnectState | null;
queuedMessages: readonly TypesGen.ChatQueuedMessage[];
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
};
type ChatStore = {
getSnapshot: () => ChatStoreState;
subscribe: (listener: () => void) => () => void;
batch: (fn: () => void) => void;
replaceMessages: (
messages: readonly TypesGen.ChatMessage[] | undefined,
) => void;
upsertDurableMessage: (message: TypesGen.ChatMessage) => {
isDuplicate: boolean;
changed: boolean;
};
upsertDurableMessages: (messages: readonly TypesGen.ChatMessage[]) => void;
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
setQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
setChatStatus: (status: TypesGen.ChatStatus | null) => void;
setStreamError: (reason: ChatDetailError | null) => void;
clearStreamError: () => void;
setRetryState: (state: RetryState | null) => void;
clearRetryState: () => void;
setReconnectState: (state: ReconnectState | null) => void;
clearReconnectState: () => void;
clearStreamState: () => void;
resetTransportReplayState: () => void;
setSubagentStatusOverride: (
chatID: string,
status: TypesGen.ChatStatus,
) => void;
resetTransientState: () => void;
};
const createInitialState = (): ChatStoreState => ({
messagesByID: new Map(),
orderedMessageIDs: [],
streamState: null,
chatStatus: null,
streamError: null,
retryState: null,
reconnectState: null,
queuedMessages: [],
subagentStatusOverrides: new Map(),
});
export const createChatStore = (): ChatStore => {
let state = createInitialState();
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) {
listener();
}
};
// Batching: suppress emit() during a batch and fire once
// at the end. This collapses N store mutations from a
// single WebSocket message into one subscriber notification.
let batchDepth = 0;
let batchDirty = false;
const batch = (fn: () => void): void => {
batchDepth += 1;
try {
fn();
} finally {
batchDepth -= 1;
if (batchDepth === 0 && batchDirty) {
batchDirty = false;
emit();
}
}
};
const setState = (
updater: (current: ChatStoreState) => ChatStoreState,
): void => {
const next = updater(state);
if (next === state) {
return;
}
state = next;
if (batchDepth > 0) {
batchDirty = true;
} else {
emit();
}
};
const replaceMessages = (
messages: readonly TypesGen.ChatMessage[] | undefined,
): void => {
const safeMessages = messages ?? [];
const nextMessagesByID = buildMessageMap(safeMessages);
const nextOrderedMessageIDs = buildOrderedMessageIDs(safeMessages);
// Fast-path: skip setState entirely when nothing changed.
if (
mapsEqualByRef(state.messagesByID, nextMessagesByID) &&
arraysEqual(state.orderedMessageIDs, nextOrderedMessageIDs)
) {
return;
}
setState((current) => {
// Re-check equality against `current` inside the updater
// to avoid overwriting a concurrent state change.
if (
mapsEqualByRef(current.messagesByID, nextMessagesByID) &&
arraysEqual(current.orderedMessageIDs, nextOrderedMessageIDs)
) {
return current;
}
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
};
const upsertDurableMessage = (message: TypesGen.ChatMessage) => {
// Use `state` for the early-return guard so we can return
// the result synchronously. The actual mutation below uses
// `current` inside the updater to avoid overwriting a
// concurrent state change (TOCTOU).
const existing = state.messagesByID.get(message.id);
const isDuplicate = state.messagesByID.has(message.id);
if (existing && chatMessagesEqualByValue(existing, message)) {
return { isDuplicate, changed: false };
}
let actuallyChanged = false;
setState((current) => {
// Re-check inside the updater: another call may have
// already applied this exact message.
const curExisting = current.messagesByID.get(message.id);
if (curExisting && chatMessagesEqualByValue(curExisting, message)) {
return current;
}
actuallyChanged = true;
const nextMessagesByID = new Map(current.messagesByID);
nextMessagesByID.set(message.id, message);
const curIsDuplicate = current.messagesByID.has(message.id);
const needsReorder =
!curIsDuplicate || nextMessagesByID.size !== current.messagesByID.size;
const nextOrderedMessageIDs = needsReorder
? buildOrderedMessageIDs(Array.from(nextMessagesByID.values()))
: current.orderedMessageIDs;
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
return { isDuplicate, changed: actuallyChanged };
};
// Bulk variant that applies all messages in a single pass —
// one Map copy and one sort instead of N copies and N sorts.
const upsertDurableMessages = (
messages: readonly TypesGen.ChatMessage[],
): void => {
if (messages.length === 0) {
return;
}
setState((current) => {
let nextMessagesByID: Map<number, TypesGen.ChatMessage> | null = null;
for (const message of messages) {
const map = nextMessagesByID ?? current.messagesByID;
const existing = map.get(message.id);
if (existing && chatMessagesEqualByValue(existing, message)) {
continue;
}
// Lazily copy the map on first actual change.
if (!nextMessagesByID) {
nextMessagesByID = new Map(current.messagesByID);
}
nextMessagesByID.set(message.id, message);
}
if (!nextMessagesByID) {
return current;
}
const needsReorder = nextMessagesByID.size !== current.messagesByID.size;
const nextOrderedMessageIDs = needsReorder
? buildOrderedMessageIDs(Array.from(nextMessagesByID.values()))
: current.orderedMessageIDs;
return {
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
};
});
};
const applyMessageParts = (parts: readonly TypesGen.ChatMessagePart[]) => {
if (parts.length === 0) {
return;
}
setState((current) => {
let nextStreamState: StreamState | null = current.streamState;
for (const part of parts) {
nextStreamState = applyMessagePartToStreamState(nextStreamState, part);
}
if (nextStreamState === current.streamState) {
return current;
}
return {
...current,
streamState: nextStreamState,
};
});
};
return {
getSnapshot: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
batch,
replaceMessages,
upsertDurableMessage,
upsertDurableMessages,
applyMessagePart: (part) => applyMessageParts([part]),
applyMessageParts,
setQueuedMessages: (queuedMessages) => {
const nextQueuedMessages = queuedMessages ?? [];
setState((current) => {
if (
chatQueuedMessagesEqualByID(
current.queuedMessages,
nextQueuedMessages,
)
) {
return current;
}
return { ...current, queuedMessages: nextQueuedMessages };
});
},
setChatStatus: (status) => {
if (state.chatStatus === status) {
return;
}
setState((current) => ({
...current,
chatStatus: status,
}));
},
setStreamError: (reason) => {
setState((current) => {
if (chatDetailErrorsEqual(current.streamError, reason)) {
return current;
}
return {
...current,
streamError: reason,
};
});
},
clearStreamError: () => {
if (state.streamError === null) {
return;
}
setState((current) => ({
...current,
streamError: null,
}));
},
setRetryState: (retryState) => {
setState((current) => {
if (retryStatesEqual(current.retryState, retryState)) {
return current;
}
return {
...current,
retryState,
};
});
},
clearRetryState: () => {
if (state.retryState === null) {
return;
}
setState((current) => ({
...current,
retryState: null,
}));
},
setReconnectState: (reconnectState) => {
setState((current) => {
if (reconnectStatesEqual(current.reconnectState, reconnectState)) {
return current;
}
return {
...current,
reconnectState,
};
});
},
clearReconnectState: () => {
if (state.reconnectState === null) {
return;
}
setState((current) => ({
...current,
reconnectState: null,
}));
},
clearStreamState: () => {
if (state.streamState === null) {
return;
}
setState((current) => ({
...current,
streamState: null,
}));
},
resetTransportReplayState: () => {
if (
state.reconnectState === null &&
state.streamState === null &&
state.streamError === null
) {
return;
}
setState((current) => ({
...current,
reconnectState: null,
streamState: null,
streamError: null,
}));
},
setSubagentStatusOverride: (chatID, status) => {
if (state.subagentStatusOverrides.get(chatID) === status) {
return;
}
setState((current) => {
if (current.subagentStatusOverrides.get(chatID) === status) {
return current;
}
const nextOverrides = new Map(current.subagentStatusOverrides);
nextOverrides.set(chatID, status);
return { ...current, subagentStatusOverrides: nextOverrides };
});
},
resetTransientState: () => {
if (
state.streamState === null &&
state.streamError === null &&
state.retryState === null &&
state.reconnectState === null &&
state.subagentStatusOverrides.size === 0
) {
return;
}
setState((current) => ({
...current,
streamState: null,
streamError: null,
retryState: null,
reconnectState: null,
subagentStatusOverrides: new Map(),
}));
},
};
};
interface UseChatStoreOptions {
chatID: string | undefined;
chatMessages: readonly TypesGen.ChatMessage[] | undefined;
@@ -586,53 +78,6 @@ interface UseChatStoreOptions {
clearChatErrorReason: (chatID: string) => void;
}
export const selectMessagesByID = (state: ChatStoreState) => state.messagesByID;
export const selectOrderedMessageIDs = (state: ChatStoreState) =>
state.orderedMessageIDs;
export const selectStreamState = (state: ChatStoreState) => state.streamState;
export const selectHasStreamState = (state: ChatStoreState) =>
state.streamState !== null;
export const selectChatStatus = (state: ChatStoreState) => state.chatStatus;
export const selectStreamError = (state: ChatStoreState) => state.streamError;
export const selectQueuedMessages = (state: ChatStoreState) =>
state.queuedMessages;
export const selectSubagentStatusOverrides = (state: ChatStoreState) =>
state.subagentStatusOverrides;
export const selectRetryState = (state: ChatStoreState) => state.retryState;
export const selectReconnectState = (state: ChatStoreState) =>
state.reconnectState;
const selectLatestDurableMessage = (
state: ChatStoreState,
): TypesGen.ChatMessage | undefined => {
const latestMessageID =
state.orderedMessageIDs[state.orderedMessageIDs.length - 1];
return latestMessageID === undefined
? undefined
: state.messagesByID.get(latestMessageID);
};
export const selectIsAwaitingFirstStreamChunk = (
state: ChatStoreState,
): boolean => {
const latestMessage = selectLatestDurableMessage(state);
const latestMessageNeedsAssistantResponse =
!latestMessage || latestMessage.role !== "assistant";
return (
state.streamState === null &&
isActiveChatStatus(state.chatStatus) &&
latestMessageNeedsAssistantResponse
);
};
export const useChatSelector = <T>(
store: ChatStore,
selector: (state: ChatStoreState) => T,
): T => {
const getSnapshot = () => selector(store.getSnapshot());
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
export const useChatStore = (
options: UseChatStoreOptions,
): { store: ChatStore; clearStreamError: () => void } => {
@@ -1076,7 +521,9 @@ export const useChatStore = (
// stream.
store.resetTransportReplayState();
},
onDisconnect(reconnectState) {
onDisconnect(
reconnectState: import("#/utils/reconnectingWebSocket").ReconnectSchedule,
) {
// Only surface reconnecting when the disconnect
// interrupted active response work. Idle watcher
// reconnects stay silent.
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "react-query";
import { chatKey } from "#/api/queries/chats";
import { useChatSelector } from "./ChatContext";
import { useChatSelector } from "./chatStore";
import type { StreamState } from "./types";
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
@@ -9,7 +9,7 @@ type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
// Only extract the toolResults record from the stream state.
// This reference is stable during pure text/thinking streaming
// and only changes when a tool result actually appears, avoiding
// a re-render of AgentDetail on every token.
// a re-render of AgentChatPage on every token.
const selectStreamToolResults = (state: {
streamState: StreamState | null;
}): Record<string, { id: string; name: string }> | null =>
@@ -1,11 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Conversation, ConversationItem } from "./conversation";
import { Message, MessageContent } from "./message";
import { Shimmer } from "./shimmer";
import { Thinking } from "./thinking";
import { Conversation, ConversationItem } from "./Conversation";
import { Message, MessageContent } from "./Message";
import { Shimmer } from "./Shimmer";
import { Thinking } from "./Thinking";
const meta: Meta<typeof Conversation> = {
title: "components/ai-elements/Conversation",
title: "pages/AgentsPage/ChatElements/Conversation",
component: Conversation,
decorators: [
(Story) => (
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { ModelSelector, type ModelSelectorOption } from "./model-selector";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
const openAIModels: ModelSelectorOption[] = [
{
@@ -46,7 +46,7 @@ const anthropicModels: ModelSelectorOption[] = [
const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels];
const meta: Meta<typeof ModelSelector> = {
title: "components/ai-elements/ModelSelector",
title: "pages/AgentsPage/ChatElements/ModelSelector",
component: ModelSelector,
decorators: [
(Story) => (
@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react";
import { ModelSelector, type ModelSelectorOption } from "./model-selector";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
const mockModelOptions: readonly ModelSelectorOption[] = [
{
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, waitFor, within } from "storybook/test";
import { Response } from "./response";
import { Response } from "./Response";
const sampleMarkdown = `
## Plan update
@@ -37,7 +37,7 @@ func ValidateToken(token string) error {
`;
const meta: Meta<typeof Response> = {
title: "components/ai-elements/Response",
title: "pages/AgentsPage/ChatElements/Response",
component: Response,
decorators: [
(Story) => (
@@ -0,0 +1,7 @@
export { ConversationItem } from "./Conversation";
export { Message, MessageContent } from "./Message";
export type { ModelSelectorOption } from "./ModelSelector";
export { ModelSelector } from "./ModelSelector";
export { Response } from "./Response";
export { Shimmer } from "./Shimmer";
export { Tool } from "./tools";
@@ -7,7 +7,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { Response } from "../response";
import { Response } from "../Response";
import { ToolCollapsible } from "./ToolCollapsible";
import type { ToolStatus } from "./utils";
@@ -6,8 +6,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ImageLightbox } from "#/pages/AgentsPage/components/ImageLightbox";
import { cn } from "#/utils/cn";
import { ImageLightbox } from "../../ImageLightbox";
import { ToolCollapsible } from "./ToolCollapsible";
import type { ToolStatus } from "./utils";
@@ -36,7 +36,7 @@ const samplePlan = [
].join("\n");
const meta: Meta<typeof Tool> = {
title: "components/ai-elements/tool/ProposePlan",
title: "pages/AgentsPage/ChatElements/tools/ProposePlan",
component: Tool,
decorators: [
(Story) => (
@@ -8,7 +8,7 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { Response } from "../response";
import { Response } from "../Response";
import type { ToolStatus } from "./utils";
export const ProposePlanTool: React.FC<{

Some files were not shown because too many files have changed in this diff Show More