refactor(site): restructure AgentsPage folder (#23648)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
getPasteDataTransfer,
|
||||
getPastedPlainText,
|
||||
isLargePaste,
|
||||
} from "../../../components/ChatMessageInput/pasteHelpers";
|
||||
} from "./pasteHelpers";
|
||||
|
||||
beforeAll(() => {
|
||||
if (typeof File.prototype.text !== "function") {
|
||||
+1
-1
@@ -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;
|
||||
@@ -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";
|
||||
+33
-146
@@ -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. */
|
||||
+1
-1
@@ -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", () => {
|
||||
+18
-18
@@ -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}
|
||||
/>{" "}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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 (0–100) 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 };
|
||||
|
||||
+46
-46
@@ -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");
|
||||
+17
-16
@@ -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
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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
-85
@@ -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;
|
||||
+1
-1
@@ -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) => (
|
||||
+3
-3
@@ -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";
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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,
|
||||
+2
-2
@@ -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
-1
@@ -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
|
||||
+1
-1
@@ -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
-1
@@ -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,
|
||||
+13
-566
@@ -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.
|
||||
+2
-2
@@ -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 =>
|
||||
+5
-5
@@ -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) => (
|
||||
+2
-2
@@ -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
-1
@@ -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[] = [
|
||||
{
|
||||
+2
-2
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+1
-1
@@ -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) => (
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user