refactor(site): restructure agents routing and directory layout (#23408)

This commit is contained in:
Danielle Maywood
2026-03-22 23:58:58 +00:00
committed by GitHub
parent b763b72b53
commit b87171086c
135 changed files with 532 additions and 457 deletions
@@ -0,0 +1,50 @@
import { chatCostSummary } from "api/queries/chats";
import { useAuthContext } from "contexts/auth/AuthProvider";
import dayjs, { type Dayjs } from "dayjs";
import type { FC } from "react";
import { useQuery } from "react-query";
import { AgentAnalyticsPageView } from "./AgentAnalyticsPageView";
import { AgentPageHeader } from "./components/AgentPageHeader";
const createDateRange = (now?: Dayjs) => {
const end = now ?? dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} ${end.format("MMM D, YYYY")}`,
};
};
interface AgentAnalyticsPageProps {
/** Override the current time for deterministic storybook snapshots. */
now?: Dayjs;
}
const AgentAnalyticsPage: FC<AgentAnalyticsPageProps> = ({ now }) => {
const { user } = useAuthContext();
const dateRange = createDateRange(now);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
enabled: Boolean(user?.id),
});
return (
<>
<AgentPageHeader />
<AgentAnalyticsPageView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
rangeLabel={dateRange.rangeLabel}
/>
</>
);
};
export default AgentAnalyticsPage;
@@ -0,0 +1,47 @@
import type { ChatCostSummary } from "api/typesGenerated";
import { BarChart3Icon } from "lucide-react";
import type { FC } from "react";
import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
import { SectionHeader } from "./components/SectionHeader";
interface AgentAnalyticsPageViewProps {
summary: ChatCostSummary | undefined;
isLoading: boolean;
error: unknown;
onRetry: () => void;
rangeLabel: string;
}
export const AgentAnalyticsPageView: FC<AgentAnalyticsPageViewProps> = ({
summary,
isLoading,
error,
onRetry,
rangeLabel,
}) => {
return (
<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">
<SectionHeader
label="Analytics"
description="Review your personal chat usage and cost breakdowns."
action={
<div className="flex items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="h-4 w-4" />
<span>{rangeLabel}</span>
</div>
}
/>
<ChatCostSummaryView
summary={summary}
isLoading={isLoading}
error={error}
onRetry={onRetry}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</div>
);
};
@@ -0,0 +1,95 @@
import { chatModelConfigs, chatModels, createChat } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import {
AgentCreateForm,
type CreateChatOptions,
} from "./components/AgentCreateForm";
import { AgentPageHeader } from "./components/AgentPageHeader";
import { ChimeButton } from "./components/ChimeButton";
import { WebPushButton } from "./components/WebPushButton";
import {
buildModelConfigIDByModelID,
getModelOptionsFromCatalog,
} from "./utils/modelOptions";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
const nilUUID = "00000000-0000-0000-0000-000000000000";
const AgentCreatePage: FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const createMutation = useMutation(createChat(queryClient));
const catalogModelOptions = getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
);
const modelConfigIDByModelID = buildModelConfigIDByModelID(
chatModelConfigsQuery.data,
);
const handleCreateChat = async ({
message,
fileIDs,
workspaceId,
model,
}: CreateChatOptions) => {
const modelConfigID =
(model && modelConfigIDByModelID.get(model)) || nilUUID;
const content: TypesGen.ChatInputPart[] = [];
if (message.trim()) {
content.push({ type: "text", text: message });
}
if (fileIDs) {
for (const fileID of fileIDs) {
content.push({ type: "file", file_id: fileID });
}
}
const createdChat = await createMutation.mutateAsync({
content,
workspace_id: workspaceId,
model_config_id: modelConfigID,
});
if (typeof window !== "undefined") {
if (modelConfigID !== nilUUID) {
localStorage.setItem(lastModelConfigIDStorageKey, modelConfigID);
} else {
localStorage.removeItem(lastModelConfigIDStorageKey);
}
}
navigate(`/agents/${createdChat.id}`);
};
const handleOpenAnalytics = () => navigate("/agents/analytics");
return (
<>
<AgentPageHeader>
<ChimeButton />
<WebPushButton />
</AgentPageHeader>
<AgentCreateForm
onCreateChat={handleCreateChat}
isCreating={createMutation.isPending}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
onOpenAnalytics={handleOpenAnalytics}
/>
</>
);
};
export default AgentCreatePage;
@@ -51,49 +51,7 @@ const AgentDetailLayout: FC = () => {
requestUnarchiveAgent: () => {}, requestUnarchiveAgent: () => {},
isSidebarCollapsed: false, isSidebarCollapsed: false,
onToggleSidebarCollapsed: () => {}, onToggleSidebarCollapsed: () => {},
modelOptions: [ onExpandSidebar: () => {},
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
],
modelConfigIDByModelID: new Map([["openai:gpt-4o", "config-1"]]),
modelIDByConfigID: new Map([["config-1", "openai:gpt-4o"]]),
modelConfigs: [
{
id: "config-1",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 70,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
],
modelCatalog: {
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
},
isModelCatalogLoading: false,
modelCatalogError: null,
desktopEnabled: false,
} satisfies AgentsOutletContext } satisfies AgentsOutletContext
} }
/> />
@@ -1,11 +1,11 @@
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { createRef } from "react"; import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { import {
draftInputStorageKeyPrefix, draftInputStorageKeyPrefix,
useConversationEditingState, useConversationEditingState,
} from "./AgentDetail"; } from "./AgentDetail";
import type { ChatMessageInputRef } from "./components/AgentChatInput";
describe("useConversationEditingState", () => { describe("useConversationEditingState", () => {
const chatID = "chat-abc-123"; const chatID = "chat-abc-123";
+49 -27
View File
@@ -3,7 +3,10 @@ import { API, watchWorkspace } from "api/api";
import { isApiError } from "api/errors"; import { isApiError } from "api/errors";
import { import {
chat, chat,
chatDesktopEnabled,
chatMessagesForInfiniteScroll, chatMessagesForInfiniteScroll,
chatModelConfigs,
chatModels,
createChatMessage, createChatMessage,
deleteChatQueuedMessage, deleteChatQueuedMessage,
editChatMessage, editChatMessage,
@@ -33,24 +36,33 @@ import type { UrlTransform } from "streamdown";
import { isMobileViewport } from "utils/mobile"; import { isMobileViewport } from "utils/mobile";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { portForwardURL } from "utils/portForward"; import { portForwardURL } from "utils/portForward";
import type { ChatMessageInputRef } from "./AgentChatInput"; import type { AgentsOutletContext } from "./AgentsPage";
import { useChatStore } from "./AgentDetail/ChatContext"; import type { ChatMessageInputRef } from "./components/AgentChatInput";
import { getParentChatID, getWorkspaceAgent } from "./AgentDetail/chatHelpers"; import { useChatStore } from "./components/AgentDetail/ChatContext";
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher"; import {
getParentChatID,
getWorkspaceAgent,
} from "./components/AgentDetail/chatHelpers";
import { useWorkspaceCreationWatcher } from "./components/AgentDetail/useWorkspaceCreationWatcher";
import { import {
AgentDetailLoadingView, AgentDetailLoadingView,
AgentDetailNotFoundView, AgentDetailNotFoundView,
AgentDetailView, AgentDetailView,
} from "./AgentDetailView"; } from "./components/AgentDetailView";
import type { AgentsOutletContext } from "./AgentsPage"; import { useGitWatcher } from "./hooks/useGitWatcher";
import { import {
buildModelConfigIDByModelID,
buildModelIDByConfigID,
getModelCatalogStatusMessage, getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
getModelSelectorPlaceholder, getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog, hasConfiguredModelsInCatalog,
} from "./modelOptions"; } from "./utils/modelOptions";
import { parsePullRequestUrl } from "./pullRequest"; import { parsePullRequestUrl } from "./utils/pullRequest";
import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import {
import { useGitWatcher } from "./useGitWatcher"; formatUsageLimitMessage,
isUsageLimitData,
} from "./utils/usageLimitMessage";
/** localStorage key controlling whether the right panel is visible. */ /** localStorage key controlling whether the right panel is visible. */
export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
@@ -222,12 +234,6 @@ export function useConversationEditingState(deps: {
const AgentDetail: FC = () => { const AgentDetail: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { agentId } = useParams<{ agentId: string }>(); const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const { const {
chatErrorReasons, chatErrorReasons,
setChatErrorReason, setChatErrorReason,
@@ -235,18 +241,14 @@ const AgentDetail: FC = () => {
requestArchiveAgent, requestArchiveAgent,
requestArchiveAndDeleteWorkspace, requestArchiveAndDeleteWorkspace,
requestUnarchiveAgent, requestUnarchiveAgent,
onOpenAnalytics,
isSidebarCollapsed, isSidebarCollapsed,
onToggleSidebarCollapsed, onToggleSidebarCollapsed,
modelOptions, } = useOutletContext<AgentsOutletContext>();
modelConfigIDByModelID, const queryClient = useQueryClient();
modelIDByConfigID, const [selectedModel, setSelectedModel] = useState("");
modelConfigs, const [pendingEditMessageId, setPendingEditMessageId] = useState<
modelCatalog, number | null
isModelCatalogLoading, >(null);
modelCatalogError,
desktopEnabled,
} = outletContext;
const scrollContainerRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null); const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef( const inputValueRef = useRef(
@@ -293,6 +295,26 @@ const AgentDetail: FC = () => {
enabled: Boolean(workspaceId), enabled: Boolean(workspaceId),
}); });
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
const modelOptions = getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
);
const modelConfigIDByModelID = buildModelConfigIDByModelID(
chatModelConfigsQuery.data,
);
const modelIDByConfigID = buildModelIDByConfigID(modelConfigIDByModelID);
const modelConfigs = chatModelConfigsQuery.data ?? [];
const modelCatalog = chatModelsQuery.data;
const isModelCatalogLoading = chatModelsQuery.isLoading;
const modelCatalogError = chatModelsQuery.error;
const handleOpenAnalytics = () => navigate("/agents/analytics");
// Subscribe to live workspace updates so that agent status changes // Subscribe to live workspace updates so that agent status changes
// (e.g. connected/disconnected) are reflected without a page refresh. // (e.g. connected/disconnected) are reflected without a page refresh.
useEffect(() => { useEffect(() => {
@@ -839,7 +861,7 @@ const AgentDetail: FC = () => {
isInterruptPending={interruptMutation.isPending} isInterruptPending={interruptMutation.isPending}
isSidebarCollapsed={isSidebarCollapsed} isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed}
onOpenAnalytics={onOpenAnalytics} onOpenAnalytics={handleOpenAnalytics}
showSidebarPanel={showSidebarPanel} showSidebarPanel={showSidebarPanel}
onSetShowSidebarPanel={handleSetShowSidebarPanel} onSetShowSidebarPanel={handleSetShowSidebarPanel}
prNumber={prNumber} prNumber={prNumber}
+6 -11
View File
@@ -9,8 +9,11 @@ import { type FC, useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { Outlet, useParams } from "react-router"; import { Outlet, useParams } from "react-router";
import type { AgentsOutletContext } from "./AgentsPage"; import type { AgentsOutletContext } from "./AgentsPage";
import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext"; import {
import type { ChatDetailError } from "./usageLimitMessage"; bootstrapChatEmbedSession,
EmbedProvider,
} from "./components/EmbedContext";
import type { ChatDetailError } from "./utils/usageLimitMessage";
type BootstrapMessage = { type BootstrapMessage = {
type: "coder:vscode-auth-bootstrap"; type: "coder:vscode-auth-bootstrap";
@@ -121,16 +124,8 @@ const AgentEmbedPage: FC = () => {
requestArchiveAndDeleteWorkspace, requestArchiveAndDeleteWorkspace,
isSidebarCollapsed, isSidebarCollapsed,
onToggleSidebarCollapsed, onToggleSidebarCollapsed,
modelOptions: [], onExpandSidebar: () => {},
modelConfigIDByModelID: new Map(),
modelIDByConfigID: new Map(),
modelConfigs: [],
modelCatalog: undefined,
isModelCatalogLoading: false,
modelCatalogError: null,
desktopEnabled: false,
}; };
// When signed out and not already bootstrapping, listen for the // When signed out and not already bootstrapping, listen for the
// postMessage from the parent frame carrying the session token. // postMessage from the parent frame carrying the session token.
const isAwaitingBootstrapMessage = const isAwaitingBootstrapMessage =
@@ -0,0 +1,23 @@
import { useAuthenticated } from "hooks";
import type { FC } from "react";
import { useParams } from "react-router";
import { AgentSettingsPageView } from "./AgentSettingsPageView";
import { AgentPageHeader } from "./components/AgentPageHeader";
const AgentSettingsPage: FC = () => {
const { section } = useParams();
const { permissions } = useAuthenticated();
const isAgentsAdmin = permissions.editDeploymentConfig;
return (
<>
<AgentPageHeader />
<AgentSettingsPageView
activeSection={section ?? "behavior"}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
/>
</>
);
};
export default AgentSettingsPage;
@@ -6,7 +6,7 @@ import { userByNameKey } from "api/queries/users";
import type * as TypesGen from "api/typesGenerated"; import type * as TypesGen from "api/typesGenerated";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { SettingsPageContent } from "./SettingsPageContent"; import { AgentSettingsPageView } from "./AgentSettingsPageView";
// ── Usage mock helpers ───────────────────────────────────────── // ── Usage mock helpers ─────────────────────────────────────────
@@ -128,8 +128,8 @@ const fixedNow = dayjs("2026-03-12T00:00:00Z");
// ── Meta ─────────────────────────────────────────────────────── // ── Meta ───────────────────────────────────────────────────────
const meta = { const meta = {
title: "pages/AgentsPage/SettingsPageContent", title: "pages/AgentsPage/AgentSettingsPageView",
component: SettingsPageContent, component: AgentSettingsPageView,
decorators: [withAuthProvider, withDashboardProvider], decorators: [withAuthProvider, withDashboardProvider],
args: { args: {
activeSection: "behavior", activeSection: "behavior",
@@ -161,10 +161,10 @@ const meta = {
}); });
spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue(); spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue();
}, },
} satisfies Meta<typeof SettingsPageContent>; } satisfies Meta<typeof AgentSettingsPageView>;
export default meta; export default meta;
type Story = StoryObj<typeof SettingsPageContent>; type Story = StoryObj<typeof AgentSettingsPageView>;
// ── Behavior tab stories ─────────────────────────────────────── // ── Behavior tab stories ───────────────────────────────────────
@@ -56,12 +56,12 @@ import {
DateRange, DateRange,
type DateRangeValue, type DateRangeValue,
} from "../TemplatePage/TemplateInsightsPage/DateRange"; } from "../TemplatePage/TemplateInsightsPage/DateRange";
import { ChatCostSummaryView } from "./ChatCostSummaryView"; import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel"; import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
import { InsightsContent } from "./InsightsContent"; import { InsightsContent } from "./components/InsightsContent";
import { LimitsTab } from "./LimitsTab"; import { LimitsTab } from "./components/LimitsTab";
import { MCPServerAdminPanel } from "./MCPServerAdminPanel"; import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
import { SectionHeader } from "./SectionHeader"; import { SectionHeader } from "./components/SectionHeader";
const AdminBadge: FC = () => ( const AdminBadge: FC = () => (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
@@ -487,7 +487,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
const textareaClassName = const textareaClassName =
"max-h-[240px] w-full resize-none overflow-y-auto 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 [scrollbar-width:thin]"; "max-h-[240px] w-full resize-none overflow-y-auto 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 [scrollbar-width:thin]";
interface SettingsPageContentProps { interface AgentSettingsPageViewProps {
activeSection: string; activeSection: string;
canManageChatModelConfigs: boolean; canManageChatModelConfigs: boolean;
canSetSystemPrompt: boolean; canSetSystemPrompt: boolean;
@@ -496,7 +496,7 @@ interface SettingsPageContentProps {
now?: dayjs.Dayjs; now?: dayjs.Dayjs;
} }
export const SettingsPageContent: FC<SettingsPageContentProps> = ({ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
activeSection, activeSection,
canManageChatModelConfigs, canManageChatModelConfigs,
canSetSystemPrompt, canSetSystemPrompt,
+4 -1
View File
@@ -1,6 +1,9 @@
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { emptyInputStorageKey, useEmptyStateDraft } from "./AgentCreateForm"; import {
emptyInputStorageKey,
useEmptyStateDraft,
} from "./components/AgentCreateForm";
describe("useEmptyStateDraft", () => { describe("useEmptyStateDraft", () => {
beforeEach(() => { beforeEach(() => {
+15 -82
View File
@@ -2,12 +2,10 @@ import { API, watchChats } from "api/api";
import { getErrorMessage } from "api/errors"; import { getErrorMessage } from "api/errors";
import { import {
archiveChat, archiveChat,
chatDesktopEnabled,
chatDiffContentsKey, chatDiffContentsKey,
chatKey, chatKey,
chatModelConfigs, chatModelConfigs,
chatModels, chatModels,
createChat,
infiniteChats, infiniteChats,
invalidateChatListQueries, invalidateChatListQueries,
prependToInfiniteChatsCache, prependToInfiniteChatsCache,
@@ -30,24 +28,14 @@ import {
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket"; import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
import {
type CreateChatOptions,
emptyInputStorageKey,
} from "./AgentCreateForm";
import { maybePlayChime } from "./AgentDetail/useAgentChime";
import { AgentsPageView } from "./AgentsPageView"; import { AgentsPageView } from "./AgentsPageView";
import { resolveArchiveAndDeleteAction } from "./agentWorkspaceUtils"; import { emptyInputStorageKey } from "./components/AgentCreateForm";
import { import { maybePlayChime } from "./components/AgentDetail/useAgentChime";
getModelOptionsFromCatalog, import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
getNormalizedModelRef, import { useAgentsPWA } from "./hooks/useAgentsPWA";
} from "./modelOptions"; import { resolveArchiveAndDeleteAction } from "./utils/agentWorkspaceUtils";
import type { ChatDetailError } from "./usageLimitMessage"; import { getModelOptionsFromCatalog } from "./utils/modelOptions";
import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings"; import type { ChatDetailError } from "./utils/usageLimitMessage";
import { useAgentsPWA } from "./useAgentsPWA";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
const nilUUID = "00000000-0000-0000-0000-000000000000";
const EMPTY_MODEL_CONFIGS: TypesGen.ChatModelConfig[] = [];
// Type guard for SSE events from the chat list watch endpoint. // Type guard for SSE events from the chat list watch endpoint.
// Shallow-compare two ChatDiffStatus objects by their meaningful // Shallow-compare two ChatDiffStatus objects by their meaningful
@@ -93,11 +81,9 @@ const AgentsPage: FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { agentId } = useParams(); const { agentId } = useParams();
const { permissions, user } = useAuthenticated(); const { permissions } = useAuthenticated();
const { appearance } = useDashboard(); const { appearance } = useDashboard();
const isAgentsAdmin = const isAgentsAdmin = permissions.editDeploymentConfig;
permissions.editDeploymentConfig ||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
const [archivedFilter, setArchivedFilter] = useState<"active" | "archived">( const [archivedFilter, setArchivedFilter] = useState<"active" | "archived">(
"active", "active",
@@ -149,10 +135,12 @@ const AgentsPage: FC = () => {
const chatsQuery = useInfiniteQuery( const chatsQuery = useInfiniteQuery(
infiniteChats({ archived: archivedFilter === "archived" }), infiniteChats({ archived: archivedFilter === "archived" }),
); );
// Model queries are kept here for the sidebar, which displays
// model info alongside each chat. Child routes that need models
// subscribe to the same queries independently — react-query
// deduplicates the requests.
const chatModelsQuery = useQuery(chatModels()); const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs()); const chatModelConfigsQuery = useQuery(chatModelConfigs());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const createMutation = useMutation(createChat(queryClient));
const archiveChatBase = archiveChat(queryClient); const archiveChatBase = archiveChat(queryClient);
const archiveAgentMutation = useMutation({ const archiveAgentMutation = useMutation({
...archiveChatBase, ...archiveChatBase,
@@ -208,24 +196,6 @@ const AgentsPage: FC = () => {
chatModelsQuery.data, chatModelsQuery.data,
chatModelConfigsQuery.data, chatModelConfigsQuery.data,
); );
const modelConfigIDByModelID = (() => {
const byModelID = new Map<string, string>();
for (const config of chatModelConfigsQuery.data ?? []) {
const { provider, model } = getNormalizedModelRef(config);
if (!provider || !model) {
continue;
}
const colonRef = `${provider}:${model}`;
if (!byModelID.has(colonRef)) {
byModelID.set(colonRef, config.id);
}
const slashRef = `${provider}/${model}`;
if (!byModelID.has(slashRef)) {
byModelID.set(slashRef, config.id);
}
}
return byModelID;
})();
const setChatErrorReason = (chatId: string, reason: ChatDetailError) => { const setChatErrorReason = (chatId: string, reason: ChatDetailError) => {
const trimmedMessage = reason.message.trim(); const trimmedMessage = reason.message.trim();
if (!chatId || !trimmedMessage) { if (!chatId || !trimmedMessage) {
@@ -317,35 +287,6 @@ const AgentsPage: FC = () => {
}; };
const handleToggleSidebarCollapsed = () => const handleToggleSidebarCollapsed = () =>
setIsSidebarCollapsed((prev) => !prev); setIsSidebarCollapsed((prev) => !prev);
const handleCreateChat = async (options: CreateChatOptions) => {
const { message, fileIDs, workspaceId, model } = options;
const modelConfigID =
(model && modelConfigIDByModelID.get(model)) || nilUUID;
const content: TypesGen.ChatInputPart[] = [];
if (message.trim()) {
content.push({ type: "text", text: message });
}
if (fileIDs) {
for (const fileID of fileIDs) {
content.push({ type: "file", file_id: fileID });
}
}
const createdChat = await createMutation.mutateAsync({
content,
workspace_id: workspaceId,
model_config_id: modelConfigID,
});
if (typeof window !== "undefined") {
if (modelConfigID !== nilUUID) {
localStorage.setItem(lastModelConfigIDStorageKey, modelConfigID);
} else {
localStorage.removeItem(lastModelConfigIDStorageKey);
}
}
navigate(`/agents/${createdChat.id}`);
};
const handleNewAgent = () => { const handleNewAgent = () => {
// Only clear the draft when the user is already on the empty // Only clear the draft when the user is already on the empty
@@ -559,10 +500,10 @@ const AgentsPage: FC = () => {
agentId={agentId} agentId={agentId}
chatList={chatList} chatList={chatList}
catalogModelOptions={catalogModelOptions} catalogModelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? EMPTY_MODEL_CONFIGS} modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url} logoUrl={appearance.logo_url}
handleNewAgent={handleNewAgent} handleNewAgent={handleNewAgent}
isCreating={createMutation.isPending} isCreating={false}
isArchiving={isArchiving} isArchiving={isArchiving}
archivingChatId={archivingChatId} archivingChatId={archivingChatId}
isChatsLoading={chatsQuery.isLoading} isChatsLoading={chatsQuery.isLoading}
@@ -578,14 +519,6 @@ const AgentsPage: FC = () => {
requestUnarchiveAgent={requestUnarchiveAgent} requestUnarchiveAgent={requestUnarchiveAgent}
requestArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace} requestArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onToggleSidebarCollapsed={handleToggleSidebarCollapsed} onToggleSidebarCollapsed={handleToggleSidebarCollapsed}
onCreateChat={handleCreateChat}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
modelConfigIDByModelID={modelConfigIDByModelID}
desktopEnabled={desktopEnabledQuery.data?.enable_desktop ?? false}
isAgentsAdmin={isAgentsAdmin} isAgentsAdmin={isAgentsAdmin}
hasNextPage={chatsQuery.hasNextPage} hasNextPage={chatsQuery.hasNextPage}
onLoadMore={() => void chatsQuery.fetchNextPage()} onLoadMore={() => void chatsQuery.fetchNextPage()}
@@ -1,4 +1,8 @@
import { MockUserOwner } from "testHelpers/entities"; import {
MockNoPermissions,
MockPermissions,
MockUserOwner,
} from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook"; import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api"; import { API } from "api/api";
@@ -18,6 +22,9 @@ import {
within, within,
} from "storybook/test"; } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { reactRouterParameters } from "storybook-addon-remix-react-router";
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import AgentSettingsPage from "./AgentSettingsPage";
import { AgentsPageView } from "./AgentsPageView"; import { AgentsPageView } from "./AgentsPageView";
const defaultModelOptions: ModelSelectorOption[] = [ const defaultModelOptions: ModelSelectorOption[] = [
@@ -103,9 +110,6 @@ const mockUsageUsers: TypesGen.ChatCostUsersResponse = {
], ],
}; };
// Use local noon so the rendered range label stays stable across timezones.
const fixedAnalyticsNow = dayjs("2026-03-12T12:00:00");
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const todayTimestamp = new Date().toISOString(); const todayTimestamp = new Date().toISOString();
@@ -123,16 +127,21 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
...overrides, ...overrides,
}); });
const agentsRouting = [ // Use local noon so the rendered range label stays stable
{ path: "/agents/settings/:section", useStoryElement: true }, // across timezones.
{ path: "/agents/settings", useStoryElement: true }, const fixedNow = dayjs("2026-03-12T12:00:00");
{ path: "/agents/analytics", useStoryElement: true },
{ path: "/agents/:agentId", useStoryElement: true }, const agentsRouting = {
{ path: "/agents", useStoryElement: true }, path: "/agents",
] satisfies [ useStoryElement: true,
{ path: string; useStoryElement: boolean }, children: [
...{ path: string; useStoryElement: boolean }[], { path: "settings", element: <AgentSettingsPage /> },
]; { path: "settings/:section", element: <AgentSettingsPage /> },
{ path: "analytics", element: <AgentAnalyticsPage now={fixedNow} /> },
{ path: ":agentId", element: <div /> },
{ index: true, element: <AgentCreatePage /> },
],
};
const meta: Meta<typeof AgentsPageView> = { const meta: Meta<typeof AgentsPageView> = {
title: "pages/AgentsPage/AgentsPageView", title: "pages/AgentsPage/AgentsPageView",
@@ -141,6 +150,7 @@ const meta: Meta<typeof AgentsPageView> = {
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
user: MockUserOwner, user: MockUserOwner,
permissions: MockPermissions,
reactRouter: reactRouterParameters({ reactRouter: reactRouterParameters({
location: { path: "/agents" }, location: { path: "/agents" },
routing: agentsRouting, routing: agentsRouting,
@@ -170,35 +180,11 @@ const meta: Meta<typeof AgentsPageView> = {
requestArchiveAndDeleteWorkspace: fn(), requestArchiveAndDeleteWorkspace: fn(),
onToggleSidebarCollapsed: fn(), onToggleSidebarCollapsed: fn(),
isAgentsAdmin: false, isAgentsAdmin: false,
analyticsNow: fixedAnalyticsNow,
archivedFilter: "active" as const, archivedFilter: "active" as const,
onArchivedFilterChange: fn(), onArchivedFilterChange: fn(),
hasNextPage: false, hasNextPage: false,
onLoadMore: fn(), onLoadMore: fn(),
isFetchingNextPage: false, isFetchingNextPage: false,
onCreateChat: fn(),
createError: undefined,
modelCatalog: {
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
},
isModelCatalogLoading: false,
isModelConfigsLoading: false,
modelCatalogError: undefined,
modelConfigIDByModelID: new Map(),
desktopEnabled: false,
}, },
beforeEach: () => { beforeEach: () => {
spyOn(API, "getWorkspaces").mockResolvedValue({ spyOn(API, "getWorkspaces").mockResolvedValue({
@@ -217,6 +203,44 @@ const meta: Meta<typeof AgentsPageView> = {
spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({ spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({
custom_prompt: "", custom_prompt: "",
}); });
// Mocks for child route pages that fetch their own data.
spyOn(API, "getChatModels").mockResolvedValue({
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
});
spyOn(API, "getChatModelConfigs").mockResolvedValue([
{
id: "config-openai-gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 70,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
]);
spyOn(API, "getChatDesktopEnabled").mockResolvedValue({
enable_desktop: false,
});
spyOn(API, "getChatWorkspaceTTL").mockResolvedValue({
workspace_ttl_ms: 0,
});
spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue();
}, },
}; };
@@ -489,6 +513,9 @@ export const OpensAnalyticsForNonAdmins: Story = {
args: { args: {
isAgentsAdmin: false, isAgentsAdmin: false,
}, },
parameters: {
permissions: MockNoPermissions,
},
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
await openAnalyticsView(canvasElement); await openAnalyticsView(canvasElement);
@@ -523,6 +550,9 @@ export const OpensSettingsForNonAdmins: Story = {
args: { args: {
isAgentsAdmin: false, isAgentsAdmin: false,
}, },
parameters: {
permissions: MockNoPermissions,
},
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
await openSettingsView(canvasElement); await openSettingsView(canvasElement);
+10 -121
View File
@@ -1,23 +1,14 @@
import type * as TypesGen from "api/typesGenerated"; import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements"; import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { Dayjs } from "dayjs";
import { PanelLeftIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router"; import { Outlet, useLocation } from "react-router";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { AgentCreateForm, type CreateChatOptions } from "./AgentCreateForm"; import {
import { AgentsSidebar, sidebarViewFromPath } from "./AgentsSidebar"; AgentsSidebar,
import { AnalyticsPageContent } from "./AnalyticsPageContent"; sidebarViewFromPath,
import { ChimeButton } from "./ChimeButton"; } from "./components/Sidebar/AgentsSidebar";
import { SettingsPageContent } from "./SettingsPageContent"; import type { ChatDetailError } from "./utils/usageLimitMessage";
import type { ChatDetailError } from "./usageLimitMessage";
import { WebPushButton } from "./WebPushButton";
type ChatModelOption = ModelSelectorOption;
export interface AgentsOutletContext { export interface AgentsOutletContext {
chatErrorReasons: Record<string, ChatDetailError>; chatErrorReasons: Record<string, ChatDetailError>;
@@ -31,21 +22,13 @@ export interface AgentsOutletContext {
) => void; ) => void;
isSidebarCollapsed: boolean; isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void; onToggleSidebarCollapsed: () => void;
onOpenAnalytics?: () => void; onExpandSidebar: () => void;
modelOptions: readonly ModelSelectorOption[];
modelConfigIDByModelID: ReadonlyMap<string, string>;
modelIDByConfigID: ReadonlyMap<string, string>;
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
isModelCatalogLoading: boolean;
modelCatalogError: unknown;
desktopEnabled: boolean;
} }
interface AgentsPageViewProps { interface AgentsPageViewProps {
agentId: string | undefined; agentId: string | undefined;
chatList: TypesGen.Chat[]; chatList: TypesGen.Chat[];
catalogModelOptions: readonly ChatModelOption[]; catalogModelOptions: readonly ModelSelectorOption[];
modelConfigs: readonly TypesGen.ChatModelConfig[]; modelConfigs: readonly TypesGen.ChatModelConfig[];
logoUrl: string; logoUrl: string;
handleNewAgent: () => void; handleNewAgent: () => void;
@@ -69,20 +52,11 @@ interface AgentsPageViewProps {
) => void; ) => void;
onToggleSidebarCollapsed: () => void; onToggleSidebarCollapsed: () => void;
isAgentsAdmin: boolean; isAgentsAdmin: boolean;
onCreateChat: (options: CreateChatOptions) => Promise<void>;
createError: unknown;
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
isModelCatalogLoading: boolean;
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
modelConfigIDByModelID: ReadonlyMap<string, string>;
desktopEnabled: boolean;
hasNextPage: boolean | undefined; hasNextPage: boolean | undefined;
onLoadMore: () => void; onLoadMore: () => void;
isFetchingNextPage: boolean; isFetchingNextPage: boolean;
archivedFilter: "active" | "archived"; archivedFilter: "active" | "archived";
onArchivedFilterChange: (filter: "active" | "archived") => void; onArchivedFilterChange: (filter: "active" | "archived") => void;
analyticsNow?: Dayjs;
} }
export const AgentsPageView: FC<AgentsPageViewProps> = ({ export const AgentsPageView: FC<AgentsPageViewProps> = ({
@@ -109,29 +83,15 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
requestArchiveAndDeleteWorkspace, requestArchiveAndDeleteWorkspace,
onToggleSidebarCollapsed, onToggleSidebarCollapsed,
isAgentsAdmin, isAgentsAdmin,
onCreateChat,
createError,
modelCatalog,
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
modelConfigIDByModelID,
desktopEnabled,
hasNextPage, hasNextPage,
onLoadMore, onLoadMore,
isFetchingNextPage, isFetchingNextPage,
archivedFilter, archivedFilter,
onArchivedFilterChange, onArchivedFilterChange,
analyticsNow,
}) => { }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const sidebarView = sidebarViewFromPath(location.pathname); const sidebarView = sidebarViewFromPath(location.pathname);
const handleOpenAnalytics = () => {
navigate("/agents/analytics");
};
// The sidebar expects plain string error messages, but the outlet // The sidebar expects plain string error messages, but the outlet
// context now carries structured ChatDetailError objects. // context now carries structured ChatDetailError objects.
const sidebarChatErrorReasons = Object.fromEntries( const sidebarChatErrorReasons = Object.fromEntries(
@@ -141,16 +101,6 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
]), ]),
); );
const modelIDByConfigID = (() => {
const byConfigID = new Map<string, string>();
for (const [modelID, configID] of modelConfigIDByModelID.entries()) {
if (!byConfigID.has(configID)) {
byConfigID.set(configID, modelID);
}
}
return byConfigID;
})();
const outletContextValue: AgentsOutletContext = { const outletContextValue: AgentsOutletContext = {
chatErrorReasons, chatErrorReasons,
setChatErrorReason, setChatErrorReason,
@@ -160,15 +110,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
requestArchiveAndDeleteWorkspace, requestArchiveAndDeleteWorkspace,
isSidebarCollapsed, isSidebarCollapsed,
onToggleSidebarCollapsed, onToggleSidebarCollapsed,
onOpenAnalytics: handleOpenAnalytics, onExpandSidebar,
modelOptions: catalogModelOptions,
modelConfigIDByModelID,
modelIDByConfigID,
modelConfigs,
modelCatalog,
isModelCatalogLoading,
modelCatalogError,
desktopEnabled,
}; };
return ( return (
@@ -217,60 +159,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
"order-1 md:order-none flex-none md:flex-1", "order-1 md:order-none flex-none md:flex-1",
)} )}
> >
{sidebarView.panel === "settings" ? ( <Outlet context={outletContextValue} />
<SettingsPageContent
activeSection={sidebarView.section}
canManageChatModelConfigs={isAgentsAdmin}
canSetSystemPrompt={isAgentsAdmin}
/>
) : sidebarView.panel === "analytics" ? (
<AnalyticsPageContent now={analyticsNow} />
) : agentId ? (
<Outlet key={agentId} context={outletContextValue} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink
to="/workspaces"
className="inline-flex shrink-0 md:hidden"
>
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onExpandSidebar}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<ChimeButton />
<WebPushButton />
</div>
</div>
<AgentCreateForm
onCreateChat={onCreateChat}
isCreating={isCreating}
createError={createError}
modelCatalog={modelCatalog}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
isModelCatalogLoading={isModelCatalogLoading}
isModelConfigsLoading={isModelConfigsLoading}
modelCatalogError={modelCatalogError}
onOpenAnalytics={handleOpenAnalytics}
/>
</>
)}
</div> </div>
</div> </div>
); );
@@ -1,66 +0,0 @@
import { chatCostSummary } from "api/queries/chats";
import { useAuthContext } from "contexts/auth/AuthProvider";
import dayjs from "dayjs";
import { BarChart3Icon } from "lucide-react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SectionHeader } from "./SectionHeader";
const createDateRange = (now?: dayjs.Dayjs) => {
const end = now ?? dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} ${end.format("MMM D, YYYY")}`,
};
};
interface AnalyticsPageContentProps {
/** Override the current time for date range calculation.
* Used for deterministic Storybook snapshots.
*/
now?: dayjs.Dayjs;
}
export const AnalyticsPageContent: FC<AnalyticsPageContentProps> = ({
now,
}) => {
const { user } = useAuthContext();
const dateRange = createDateRange(now);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
enabled: Boolean(user?.id),
});
return (
<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">
<SectionHeader
label="Analytics"
description="Review your personal chat usage and cost breakdowns."
action={
<div className="flex items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="h-4 w-4" />
<span>{dateRange.rangeLabel}</span>
</div>
}
/>
<ChatCostSummaryView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</div>
);
};
@@ -36,8 +36,8 @@ import {
} from "react"; } from "react";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { isMobileViewport } from "utils/mobile"; import { isMobileViewport } from "utils/mobile";
import { formatProviderLabel } from "../utils/modelOptions";
import { ImageLightbox } from "./ImageLightbox"; import { ImageLightbox } from "./ImageLightbox";
import { formatProviderLabel } from "./modelOptions";
import { QueuedMessagesList } from "./QueuedMessagesList"; import { QueuedMessagesList } from "./QueuedMessagesList";
export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput"; export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
@@ -24,15 +24,18 @@ import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, useEffect, useRef, useState } from "react"; import { type FC, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { AgentChatInput } from "./AgentChatInput"; import { useFileAttachments } from "../hooks/useFileAttachments";
import { import {
getModelCatalogStatusMessage, getModelCatalogStatusMessage,
getModelSelectorPlaceholder, getModelSelectorPlaceholder,
getNormalizedModelRef, getNormalizedModelRef,
hasConfiguredModelsInCatalog, hasConfiguredModelsInCatalog,
} from "./modelOptions"; } from "../utils/modelOptions";
import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import {
import { useFileAttachments } from "./useFileAttachments"; formatUsageLimitMessage,
isUsageLimitData,
} from "../utils/usageLimitMessage";
import { AgentChatInput } from "./AgentChatInput";
/** @internal Exported for testing. */ /** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input"; export const emptyInputStorageKey = "agents.empty-input";
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { type InfiniteData, useQueryClient } from "react-query"; import { type InfiniteData, useQueryClient } from "react-query";
import type { OneWayMessageEvent } from "utils/OneWayWebSocket"; import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket"; import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
import type { ChatDetailError } from "../usageLimitMessage"; import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { applyMessagePartToStreamState } from "./streamState"; import { applyMessagePartToStreamState } from "./streamState";
import type { StreamState } from "./types"; import type { StreamState } from "./types";
@@ -29,9 +29,9 @@ import {
} from "react"; } from "react";
import type { UrlTransform } from "streamdown"; import type { UrlTransform } from "streamdown";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { ImageThumbnail } from "../AgentChatInput"; import { ImageThumbnail } from "../AgentChatInput";
import { ImageLightbox } from "../ImageLightbox"; import { ImageLightbox } from "../ImageLightbox";
import type { ChatDetailError } from "../usageLimitMessage";
import { useSmoothStreamingText } from "./SmoothText"; import { useSmoothStreamingText } from "./SmoothText";
import type { import type {
MergedTool, MergedTool,
@@ -27,9 +27,9 @@ import type { FC } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { parsePullRequestUrl } from "../../utils/pullRequest";
import { useEmbedContext } from "../EmbedContext"; import { useEmbedContext } from "../EmbedContext";
import { PrStateIcon } from "../GitPanel"; import { PrStateIcon } from "../GitPanel/GitPanel";
import { parsePullRequestUrl } from "../pullRequest";
interface SidebarPanelState { interface SidebarPanelState {
showSidebarPanel: boolean; showSidebarPanel: boolean;
@@ -4,6 +4,8 @@ import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, useEffect } from "react"; import { type FC, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { UrlTransform } from "streamdown"; import type { UrlTransform } from "streamdown";
import { useFileAttachments } from "../hooks/useFileAttachments";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import { import {
AgentChatInput, AgentChatInput,
type ChatMessageInputRef, type ChatMessageInputRef,
@@ -30,8 +32,6 @@ import {
} from "./AgentDetail/messageParsing"; } from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState"; import { buildStreamTools } from "./AgentDetail/streamState";
import type { ParsedMessageEntry } from "./AgentDetail/types"; import type { ParsedMessageEntry } from "./AgentDetail/types";
import type { ChatDetailError } from "./usageLimitMessage";
import { useFileAttachments } from "./useFileAttachments";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"]; type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -7,6 +7,7 @@ import { type FC, type RefObject, useEffect, useRef, useState } from "react";
import type { UrlTransform } from "streamdown"; import type { UrlTransform } from "streamdown";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import type { ChatDetailError } from "../utils/usageLimitMessage";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import { import {
selectChatStatus, selectChatStatus,
@@ -19,10 +20,9 @@ import {
ChatConversationSkeleton, ChatConversationSkeleton,
RightPanelSkeleton, RightPanelSkeleton,
} from "./AgentsSkeletons"; } from "./AgentsSkeletons";
import { GitPanel } from "./GitPanel"; import { GitPanel } from "./GitPanel/GitPanel";
import { RightPanel } from "./RightPanel"; import { RightPanel } from "./RightPanel/RightPanel";
import { SidebarTabView } from "./SidebarTabView"; import { SidebarTabView } from "./Sidebar/SidebarTabView";
import type { ChatDetailError } from "./usageLimitMessage";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"]; type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -0,0 +1,44 @@
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { PanelLeftIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC, ReactNode } from "react";
import { NavLink, useOutletContext } from "react-router";
import type { AgentsOutletContext } from "../AgentsPageView";
interface AgentPageHeaderProps {
children?: ReactNode;
}
export const AgentPageHeader: FC<AgentPageHeaderProps> = ({ children }) => {
const { isSidebarCollapsed, onExpandSidebar } =
useOutletContext<AgentsOutletContext>();
const { appearance } = useDashboard();
const logoUrl = appearance.logo_url;
return (
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink to="/workspaces" className="inline-flex shrink-0 md:hidden">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onExpandSidebar}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="min-w-0 flex-1" />
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
);
};
@@ -16,7 +16,7 @@ import { Spinner } from "components/Spinner/Spinner";
import { type FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions"; import { formatProviderLabel } from "../../utils/modelOptions";
import { normalizeProvider, readOptionalString } from "./helpers"; import { normalizeProvider, readOptionalString } from "./helpers";
import { ModelsSection } from "./ModelsSection"; import { ModelsSection } from "./ModelsSection";
import { ProvidersSection } from "./ProvidersSection"; import { ProvidersSection } from "./ProvidersSection";
@@ -10,7 +10,7 @@ import {
} from "components/Tooltip/Tooltip"; } from "components/Tooltip/Tooltip";
import { ChevronLeftIcon, InfoIcon } from "lucide-react"; import { ChevronLeftIcon, InfoIcon } from "lucide-react";
import { type FC, type FormEvent, useId, useState } from "react"; import { type FC, type FormEvent, useId, useState } from "react";
import { formatProviderLabel } from "../modelOptions"; import { formatProviderLabel } from "../../utils/modelOptions";
import type { ProviderState } from "./ChatModelAdminPanel"; import type { ProviderState } from "./ChatModelAdminPanel";
import { readOptionalString } from "./helpers"; import { readOptionalString } from "./helpers";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
@@ -2,7 +2,7 @@ import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { ServerIcon } from "lucide-react"; import { ServerIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions"; import { formatProviderLabel } from "../../utils/modelOptions";
import { normalizeProvider } from "./helpers"; import { normalizeProvider } from "./helpers";
const providerIconMap: Record<string, string> = { const providerIconMap: Record<string, string> = {
@@ -12,9 +12,6 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { ChatMessageInputRef } from "./AgentChatInput";
import type { DiffStyle } from "./DiffViewer";
import { DiffViewer } from "./DiffViewer";
import { import {
annotationLineForBox, annotationLineForBox,
annotationSideForBox, annotationSideForBox,
@@ -22,7 +19,10 @@ import {
commentBoxFromRange, commentBoxFromRange,
contentRangeForBox, contentRangeForBox,
selectedLinesForBox, selectedLinesForBox,
} from "./diffCommentSelection"; } from "../../utils/diffCommentSelection";
import type { ChatMessageInputRef } from "../AgentChatInput";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
import { DiffViewer } from "../DiffViewer/DiffViewer";
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Diff content extraction // Diff content extraction
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { DiffStatBadge } from "./DiffStats"; import { DiffStatBadge } from "../DiffViewer/DiffStats";
const badgeMeta: Meta<typeof DiffStatBadge> = { const badgeMeta: Meta<typeof DiffStatBadge> = {
title: "pages/AgentsPage/DiffStatBadge", title: "pages/AgentsPage/DiffStatBadge",
@@ -2,9 +2,9 @@ import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, waitFor } from "storybook/test"; import { expect, fn, waitFor } from "storybook/test";
import type { DiffStyle } from "./DiffViewer"; import type { DiffStyle } from "../DiffViewer/DiffViewer";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "../DiffViewer/DiffViewer";
import { InlinePromptInput } from "./RemoteDiffPanel"; import { InlinePromptInput } from "../DiffViewer/RemoteDiffPanel";
// biome-ignore format: raw diff string must preserve exact whitespace // biome-ignore format: raw diff string must preserve exact whitespace
const sampleDiff = [ const sampleDiff = [
@@ -24,7 +24,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { changeColor, changeLabel } from "./diffColors"; import { changeColor, changeLabel } from "../../utils/diffColors";
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Public interface // Public interface
@@ -1,9 +1,9 @@
import { parsePatchFiles } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated"; import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import type { FC, RefObject } from "react"; import type { FC, RefObject } from "react";
import type { ChatMessageInputRef } from "./AgentChatInput"; import type { ChatMessageInputRef } from "../AgentChatInput";
import { CommentableDiffViewer } from "./CommentableDiffViewer"; import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
import type { DiffStyle } from "./DiffViewer"; import type { DiffStyle } from "../DiffViewer/DiffViewer";
interface LocalDiffPanelProps { interface LocalDiffPanelProps {
repo: WorkspaceAgentRepoChanges; repo: WorkspaceAgentRepoChanges;
@@ -14,13 +14,13 @@ import {
import { type FC, type RefObject, useEffect, useState } from "react"; import { type FC, type RefObject, useEffect, useState } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput"; import { parsePullRequestUrl } from "../../utils/pullRequest";
import { CommentableDiffViewer } from "./CommentableDiffViewer"; import type { ChatMessageInputRef } from "../AgentChatInput";
import { DiffStatBadge } from "./DiffStats"; import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
import type { DiffStyle } from "./DiffViewer"; import { DiffStatBadge } from "../DiffViewer/DiffStats";
import { parsePullRequestUrl } from "./pullRequest"; import type { DiffStyle } from "../DiffViewer/DiffViewer";
export { InlinePromptInput } from "./CommentableDiffViewer"; export { InlinePromptInput } from "../DiffViewer/CommentableDiffViewer";
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// PR state badge // PR state badge
@@ -1,6 +1,6 @@
import { parsePatchFiles } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { extractDiffContent } from "./CommentableDiffViewer"; import { extractDiffContent } from "../DiffViewer/CommentableDiffViewer";
function parse(diffStr: string) { function parse(diffStr: string) {
return parsePatchFiles(diffStr).flatMap((p) => p.files); return parsePatchFiles(diffStr).flatMap((p) => p.files);
@@ -19,11 +19,15 @@ import {
} from "lucide-react"; } from "lucide-react";
import { type FC, type RefObject, useEffect, useRef, useState } from "react"; import { type FC, type RefObject, useEffect, useRef, useState } from "react";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput"; import type { ChatMessageInputRef } from "../AgentChatInput";
import { DiffStatBadge } from "./DiffStats"; import { DiffStatBadge } from "../DiffViewer/DiffStats";
import { type DiffStyle, loadDiffStyle, saveDiffStyle } from "./DiffViewer"; import {
import { LocalDiffPanel } from "./LocalDiffPanel"; type DiffStyle,
import { RemoteDiffPanel } from "./RemoteDiffPanel"; loadDiffStyle,
saveDiffStyle,
} from "../DiffViewer/DiffViewer";
import { LocalDiffPanel } from "../DiffViewer/LocalDiffPanel";
import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
type GitView = { type: "remote" } | { type: "local"; repoRoot: string }; type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test"; import { expect, fn, userEvent, within } from "storybook/test";
import { InlinePromptInput } from "./RemoteDiffPanel"; import { InlinePromptInput } from "../DiffViewer/RemoteDiffPanel";
const meta: Meta<typeof InlinePromptInput> = { const meta: Meta<typeof InlinePromptInput> = {
title: "pages/AgentsPage/InlinePromptInput", title: "pages/AgentsPage/InlinePromptInput",
@@ -21,7 +21,7 @@ import type { FC } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { cn } from "utils/cn"; import { cn } from "utils/cn";
import { formatCostMicros } from "utils/currency"; import { formatCostMicros } from "utils/currency";
import { PrStateIcon } from "./GitPanel"; import { PrStateIcon } from "./GitPanel/GitPanel";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);

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