mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor(site): restructure agents routing and directory layout (#23408)
This commit is contained in:
@@ -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: () => {},
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
modelOptions: [
|
||||
{
|
||||
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,
|
||||
onExpandSidebar: () => {},
|
||||
} satisfies AgentsOutletContext
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import {
|
||||
draftInputStorageKeyPrefix,
|
||||
useConversationEditingState,
|
||||
} from "./AgentDetail";
|
||||
import type { ChatMessageInputRef } from "./components/AgentChatInput";
|
||||
|
||||
describe("useConversationEditingState", () => {
|
||||
const chatID = "chat-abc-123";
|
||||
|
||||
@@ -3,7 +3,10 @@ import { API, watchWorkspace } from "api/api";
|
||||
import { isApiError } from "api/errors";
|
||||
import {
|
||||
chat,
|
||||
chatDesktopEnabled,
|
||||
chatMessagesForInfiniteScroll,
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
createChatMessage,
|
||||
deleteChatQueuedMessage,
|
||||
editChatMessage,
|
||||
@@ -33,24 +36,33 @@ import type { UrlTransform } from "streamdown";
|
||||
import { isMobileViewport } from "utils/mobile";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { portForwardURL } from "utils/portForward";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { useChatStore } from "./AgentDetail/ChatContext";
|
||||
import { getParentChatID, getWorkspaceAgent } from "./AgentDetail/chatHelpers";
|
||||
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import type { ChatMessageInputRef } from "./components/AgentChatInput";
|
||||
import { useChatStore } from "./components/AgentDetail/ChatContext";
|
||||
import {
|
||||
getParentChatID,
|
||||
getWorkspaceAgent,
|
||||
} from "./components/AgentDetail/chatHelpers";
|
||||
import { useWorkspaceCreationWatcher } from "./components/AgentDetail/useWorkspaceCreationWatcher";
|
||||
import {
|
||||
AgentDetailLoadingView,
|
||||
AgentDetailNotFoundView,
|
||||
AgentDetailView,
|
||||
} from "./AgentDetailView";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
} from "./components/AgentDetailView";
|
||||
import { useGitWatcher } from "./hooks/useGitWatcher";
|
||||
import {
|
||||
buildModelConfigIDByModelID,
|
||||
buildModelIDByConfigID,
|
||||
getModelCatalogStatusMessage,
|
||||
getModelOptionsFromCatalog,
|
||||
getModelSelectorPlaceholder,
|
||||
hasConfiguredModelsInCatalog,
|
||||
} from "./modelOptions";
|
||||
import { parsePullRequestUrl } from "./pullRequest";
|
||||
import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage";
|
||||
import { useGitWatcher } from "./useGitWatcher";
|
||||
} from "./utils/modelOptions";
|
||||
import { parsePullRequestUrl } from "./utils/pullRequest";
|
||||
import {
|
||||
formatUsageLimitMessage,
|
||||
isUsageLimitData,
|
||||
} from "./utils/usageLimitMessage";
|
||||
|
||||
/** localStorage key controlling whether the right panel is visible. */
|
||||
export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
|
||||
@@ -222,12 +234,6 @@ export function useConversationEditingState(deps: {
|
||||
const AgentDetail: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const outletContext = useOutletContext<AgentsOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [pendingEditMessageId, setPendingEditMessageId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
@@ -235,18 +241,14 @@ const AgentDetail: FC = () => {
|
||||
requestArchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
requestUnarchiveAgent,
|
||||
onOpenAnalytics,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
modelOptions,
|
||||
modelConfigIDByModelID,
|
||||
modelIDByConfigID,
|
||||
modelConfigs,
|
||||
modelCatalog,
|
||||
isModelCatalogLoading,
|
||||
modelCatalogError,
|
||||
desktopEnabled,
|
||||
} = outletContext;
|
||||
} = useOutletContext<AgentsOutletContext>();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [pendingEditMessageId, setPendingEditMessageId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
|
||||
const inputValueRef = useRef(
|
||||
@@ -293,6 +295,26 @@ const AgentDetail: FC = () => {
|
||||
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
|
||||
// (e.g. connected/disconnected) are reflected without a page refresh.
|
||||
useEffect(() => {
|
||||
@@ -839,7 +861,7 @@ const AgentDetail: FC = () => {
|
||||
isInterruptPending={interruptMutation.isPending}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
onOpenAnalytics={onOpenAnalytics}
|
||||
onOpenAnalytics={handleOpenAnalytics}
|
||||
showSidebarPanel={showSidebarPanel}
|
||||
onSetShowSidebarPanel={handleSetShowSidebarPanel}
|
||||
prNumber={prNumber}
|
||||
|
||||
@@ -9,8 +9,11 @@ import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import {
|
||||
bootstrapChatEmbedSession,
|
||||
EmbedProvider,
|
||||
} from "./components/EmbedContext";
|
||||
import type { ChatDetailError } from "./utils/usageLimitMessage";
|
||||
|
||||
type BootstrapMessage = {
|
||||
type: "coder:vscode-auth-bootstrap";
|
||||
@@ -121,16 +124,8 @@ const AgentEmbedPage: FC = () => {
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
modelOptions: [],
|
||||
modelConfigIDByModelID: new Map(),
|
||||
modelIDByConfigID: new Map(),
|
||||
modelConfigs: [],
|
||||
modelCatalog: undefined,
|
||||
isModelCatalogLoading: false,
|
||||
modelCatalogError: null,
|
||||
desktopEnabled: false,
|
||||
onExpandSidebar: () => {},
|
||||
};
|
||||
|
||||
// When signed out and not already bootstrapping, listen for the
|
||||
// postMessage from the parent frame carrying the session token.
|
||||
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;
|
||||
+5
-5
@@ -6,7 +6,7 @@ import { userByNameKey } from "api/queries/users";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import dayjs from "dayjs";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { SettingsPageContent } from "./SettingsPageContent";
|
||||
import { AgentSettingsPageView } from "./AgentSettingsPageView";
|
||||
|
||||
// ── Usage mock helpers ─────────────────────────────────────────
|
||||
|
||||
@@ -128,8 +128,8 @@ const fixedNow = dayjs("2026-03-12T00:00:00Z");
|
||||
// ── Meta ───────────────────────────────────────────────────────
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/SettingsPageContent",
|
||||
component: SettingsPageContent,
|
||||
title: "pages/AgentsPage/AgentSettingsPageView",
|
||||
component: AgentSettingsPageView,
|
||||
decorators: [withAuthProvider, withDashboardProvider],
|
||||
args: {
|
||||
activeSection: "behavior",
|
||||
@@ -161,10 +161,10 @@ const meta = {
|
||||
});
|
||||
spyOn(API, "updateChatWorkspaceTTL").mockResolvedValue();
|
||||
},
|
||||
} satisfies Meta<typeof SettingsPageContent>;
|
||||
} satisfies Meta<typeof AgentSettingsPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsPageContent>;
|
||||
type Story = StoryObj<typeof AgentSettingsPageView>;
|
||||
|
||||
// ── Behavior tab stories ───────────────────────────────────────
|
||||
|
||||
+8
-8
@@ -56,12 +56,12 @@ import {
|
||||
DateRange,
|
||||
type DateRangeValue,
|
||||
} from "../TemplatePage/TemplateInsightsPage/DateRange";
|
||||
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
||||
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { InsightsContent } from "./InsightsContent";
|
||||
import { LimitsTab } from "./LimitsTab";
|
||||
import { MCPServerAdminPanel } from "./MCPServerAdminPanel";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
|
||||
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { InsightsContent } from "./components/InsightsContent";
|
||||
import { LimitsTab } from "./components/LimitsTab";
|
||||
import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
|
||||
const AdminBadge: FC = () => (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -487,7 +487,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
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]";
|
||||
|
||||
interface SettingsPageContentProps {
|
||||
interface AgentSettingsPageViewProps {
|
||||
activeSection: string;
|
||||
canManageChatModelConfigs: boolean;
|
||||
canSetSystemPrompt: boolean;
|
||||
@@ -496,7 +496,7 @@ interface SettingsPageContentProps {
|
||||
now?: dayjs.Dayjs;
|
||||
}
|
||||
|
||||
export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
||||
export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
activeSection,
|
||||
canManageChatModelConfigs,
|
||||
canSetSystemPrompt,
|
||||
@@ -1,6 +1,9 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { emptyInputStorageKey, useEmptyStateDraft } from "./AgentCreateForm";
|
||||
import {
|
||||
emptyInputStorageKey,
|
||||
useEmptyStateDraft,
|
||||
} from "./components/AgentCreateForm";
|
||||
|
||||
describe("useEmptyStateDraft", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,12 +2,10 @@ import { API, watchChats } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import {
|
||||
archiveChat,
|
||||
chatDesktopEnabled,
|
||||
chatDiffContentsKey,
|
||||
chatKey,
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
createChat,
|
||||
infiniteChats,
|
||||
invalidateChatListQueries,
|
||||
prependToInfiniteChatsCache,
|
||||
@@ -30,24 +28,14 @@ import {
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
|
||||
import {
|
||||
type CreateChatOptions,
|
||||
emptyInputStorageKey,
|
||||
} from "./AgentCreateForm";
|
||||
import { maybePlayChime } from "./AgentDetail/useAgentChime";
|
||||
import { AgentsPageView } from "./AgentsPageView";
|
||||
import { resolveArchiveAndDeleteAction } from "./agentWorkspaceUtils";
|
||||
import {
|
||||
getModelOptionsFromCatalog,
|
||||
getNormalizedModelRef,
|
||||
} from "./modelOptions";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings";
|
||||
import { useAgentsPWA } from "./useAgentsPWA";
|
||||
|
||||
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
|
||||
const nilUUID = "00000000-0000-0000-0000-000000000000";
|
||||
const EMPTY_MODEL_CONFIGS: TypesGen.ChatModelConfig[] = [];
|
||||
import { emptyInputStorageKey } from "./components/AgentCreateForm";
|
||||
import { maybePlayChime } from "./components/AgentDetail/useAgentChime";
|
||||
import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
|
||||
import { useAgentsPWA } from "./hooks/useAgentsPWA";
|
||||
import { resolveArchiveAndDeleteAction } from "./utils/agentWorkspaceUtils";
|
||||
import { getModelOptionsFromCatalog } from "./utils/modelOptions";
|
||||
import type { ChatDetailError } from "./utils/usageLimitMessage";
|
||||
|
||||
// Type guard for SSE events from the chat list watch endpoint.
|
||||
// Shallow-compare two ChatDiffStatus objects by their meaningful
|
||||
@@ -93,11 +81,9 @@ const AgentsPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { agentId } = useParams();
|
||||
const { permissions, user } = useAuthenticated();
|
||||
const { permissions } = useAuthenticated();
|
||||
const { appearance } = useDashboard();
|
||||
const isAgentsAdmin =
|
||||
permissions.editDeploymentConfig ||
|
||||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
|
||||
const isAgentsAdmin = permissions.editDeploymentConfig;
|
||||
|
||||
const [archivedFilter, setArchivedFilter] = useState<"active" | "archived">(
|
||||
"active",
|
||||
@@ -149,10 +135,12 @@ const AgentsPage: FC = () => {
|
||||
const chatsQuery = useInfiniteQuery(
|
||||
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 chatModelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
|
||||
const createMutation = useMutation(createChat(queryClient));
|
||||
const archiveChatBase = archiveChat(queryClient);
|
||||
const archiveAgentMutation = useMutation({
|
||||
...archiveChatBase,
|
||||
@@ -208,24 +196,6 @@ const AgentsPage: FC = () => {
|
||||
chatModelsQuery.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 trimmedMessage = reason.message.trim();
|
||||
if (!chatId || !trimmedMessage) {
|
||||
@@ -317,35 +287,6 @@ const AgentsPage: FC = () => {
|
||||
};
|
||||
const handleToggleSidebarCollapsed = () =>
|
||||
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 = () => {
|
||||
// Only clear the draft when the user is already on the empty
|
||||
@@ -559,10 +500,10 @@ const AgentsPage: FC = () => {
|
||||
agentId={agentId}
|
||||
chatList={chatList}
|
||||
catalogModelOptions={catalogModelOptions}
|
||||
modelConfigs={chatModelConfigsQuery.data ?? EMPTY_MODEL_CONFIGS}
|
||||
modelConfigs={chatModelConfigsQuery.data ?? []}
|
||||
logoUrl={appearance.logo_url}
|
||||
handleNewAgent={handleNewAgent}
|
||||
isCreating={createMutation.isPending}
|
||||
isCreating={false}
|
||||
isArchiving={isArchiving}
|
||||
archivingChatId={archivingChatId}
|
||||
isChatsLoading={chatsQuery.isLoading}
|
||||
@@ -578,14 +519,6 @@ const AgentsPage: FC = () => {
|
||||
requestUnarchiveAgent={requestUnarchiveAgent}
|
||||
requestArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
|
||||
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}
|
||||
hasNextPage={chatsQuery.hasNextPage}
|
||||
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 type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
@@ -18,6 +22,9 @@ import {
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import AgentAnalyticsPage from "./AgentAnalyticsPage";
|
||||
import AgentCreatePage from "./AgentCreatePage";
|
||||
import AgentSettingsPage from "./AgentSettingsPage";
|
||||
import { AgentsPageView } from "./AgentsPageView";
|
||||
|
||||
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 todayTimestamp = new Date().toISOString();
|
||||
|
||||
@@ -123,16 +127,21 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const agentsRouting = [
|
||||
{ path: "/agents/settings/:section", useStoryElement: true },
|
||||
{ path: "/agents/settings", useStoryElement: true },
|
||||
{ path: "/agents/analytics", useStoryElement: true },
|
||||
{ path: "/agents/:agentId", useStoryElement: true },
|
||||
{ path: "/agents", useStoryElement: true },
|
||||
] satisfies [
|
||||
{ path: string; useStoryElement: boolean },
|
||||
...{ path: string; useStoryElement: boolean }[],
|
||||
];
|
||||
// Use local noon so the rendered range label stays stable
|
||||
// across timezones.
|
||||
const fixedNow = dayjs("2026-03-12T12:00:00");
|
||||
|
||||
const agentsRouting = {
|
||||
path: "/agents",
|
||||
useStoryElement: true,
|
||||
children: [
|
||||
{ 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> = {
|
||||
title: "pages/AgentsPage/AgentsPageView",
|
||||
@@ -141,6 +150,7 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
user: MockUserOwner,
|
||||
permissions: MockPermissions,
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/agents" },
|
||||
routing: agentsRouting,
|
||||
@@ -170,35 +180,11 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
requestArchiveAndDeleteWorkspace: fn(),
|
||||
onToggleSidebarCollapsed: fn(),
|
||||
isAgentsAdmin: false,
|
||||
analyticsNow: fixedAnalyticsNow,
|
||||
archivedFilter: "active" as const,
|
||||
onArchivedFilterChange: fn(),
|
||||
hasNextPage: false,
|
||||
onLoadMore: fn(),
|
||||
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: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
@@ -217,6 +203,44 @@ const meta: Meta<typeof AgentsPageView> = {
|
||||
spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({
|
||||
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: {
|
||||
isAgentsAdmin: false,
|
||||
},
|
||||
parameters: {
|
||||
permissions: MockNoPermissions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await openAnalyticsView(canvasElement);
|
||||
|
||||
@@ -523,6 +550,9 @@ export const OpensSettingsForNonAdmins: Story = {
|
||||
args: {
|
||||
isAgentsAdmin: false,
|
||||
},
|
||||
parameters: {
|
||||
permissions: MockNoPermissions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettingsView(canvasElement);
|
||||
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
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 { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { AgentCreateForm, type CreateChatOptions } from "./AgentCreateForm";
|
||||
import { AgentsSidebar, sidebarViewFromPath } from "./AgentsSidebar";
|
||||
import { AnalyticsPageContent } from "./AnalyticsPageContent";
|
||||
import { ChimeButton } from "./ChimeButton";
|
||||
import { SettingsPageContent } from "./SettingsPageContent";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import { WebPushButton } from "./WebPushButton";
|
||||
|
||||
type ChatModelOption = ModelSelectorOption;
|
||||
import {
|
||||
AgentsSidebar,
|
||||
sidebarViewFromPath,
|
||||
} from "./components/Sidebar/AgentsSidebar";
|
||||
import type { ChatDetailError } from "./utils/usageLimitMessage";
|
||||
|
||||
export interface AgentsOutletContext {
|
||||
chatErrorReasons: Record<string, ChatDetailError>;
|
||||
@@ -31,21 +22,13 @@ export interface AgentsOutletContext {
|
||||
) => void;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
onOpenAnalytics?: () => 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;
|
||||
onExpandSidebar: () => void;
|
||||
}
|
||||
|
||||
interface AgentsPageViewProps {
|
||||
agentId: string | undefined;
|
||||
chatList: TypesGen.Chat[];
|
||||
catalogModelOptions: readonly ChatModelOption[];
|
||||
catalogModelOptions: readonly ModelSelectorOption[];
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
logoUrl: string;
|
||||
handleNewAgent: () => void;
|
||||
@@ -69,20 +52,11 @@ interface AgentsPageViewProps {
|
||||
) => void;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
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;
|
||||
onLoadMore: () => void;
|
||||
isFetchingNextPage: boolean;
|
||||
archivedFilter: "active" | "archived";
|
||||
onArchivedFilterChange: (filter: "active" | "archived") => void;
|
||||
analyticsNow?: Dayjs;
|
||||
}
|
||||
|
||||
export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
@@ -109,29 +83,15 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
onToggleSidebarCollapsed,
|
||||
isAgentsAdmin,
|
||||
onCreateChat,
|
||||
createError,
|
||||
modelCatalog,
|
||||
isModelCatalogLoading,
|
||||
isModelConfigsLoading,
|
||||
modelCatalogError,
|
||||
modelConfigIDByModelID,
|
||||
desktopEnabled,
|
||||
hasNextPage,
|
||||
onLoadMore,
|
||||
isFetchingNextPage,
|
||||
archivedFilter,
|
||||
onArchivedFilterChange,
|
||||
analyticsNow,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const sidebarView = sidebarViewFromPath(location.pathname);
|
||||
|
||||
const handleOpenAnalytics = () => {
|
||||
navigate("/agents/analytics");
|
||||
};
|
||||
|
||||
// The sidebar expects plain string error messages, but the outlet
|
||||
// context now carries structured ChatDetailError objects.
|
||||
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 = {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
@@ -160,15 +110,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
onOpenAnalytics: handleOpenAnalytics,
|
||||
modelOptions: catalogModelOptions,
|
||||
modelConfigIDByModelID,
|
||||
modelIDByConfigID,
|
||||
modelConfigs,
|
||||
modelCatalog,
|
||||
isModelCatalogLoading,
|
||||
modelCatalogError,
|
||||
desktopEnabled,
|
||||
onExpandSidebar,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -217,60 +159,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
||||
"order-1 md:order-none flex-none md:flex-1",
|
||||
)}
|
||||
>
|
||||
{sidebarView.panel === "settings" ? (
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Outlet context={outletContextValue} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -36,8 +36,8 @@ import {
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { isMobileViewport } from "utils/mobile";
|
||||
import { formatProviderLabel } from "../utils/modelOptions";
|
||||
import { ImageLightbox } from "./ImageLightbox";
|
||||
import { formatProviderLabel } from "./modelOptions";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
|
||||
export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
|
||||
+7
-4
@@ -24,15 +24,18 @@ import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
import { useFileAttachments } from "../hooks/useFileAttachments";
|
||||
import {
|
||||
getModelCatalogStatusMessage,
|
||||
getModelSelectorPlaceholder,
|
||||
getNormalizedModelRef,
|
||||
hasConfiguredModelsInCatalog,
|
||||
} from "./modelOptions";
|
||||
import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage";
|
||||
import { useFileAttachments } from "./useFileAttachments";
|
||||
} from "../utils/modelOptions";
|
||||
import {
|
||||
formatUsageLimitMessage,
|
||||
isUsageLimitData,
|
||||
} from "../utils/usageLimitMessage";
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export const emptyInputStorageKey = "agents.empty-input";
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { type InfiniteData, useQueryClient } from "react-query";
|
||||
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
|
||||
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
|
||||
import type { ChatDetailError } from "../usageLimitMessage";
|
||||
import type { ChatDetailError } from "../../utils/usageLimitMessage";
|
||||
import { applyMessagePartToStreamState } from "./streamState";
|
||||
import type { StreamState } from "./types";
|
||||
|
||||
+1
-1
@@ -29,9 +29,9 @@ import {
|
||||
} from "react";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatDetailError } from "../../utils/usageLimitMessage";
|
||||
import { ImageThumbnail } from "../AgentChatInput";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import type { ChatDetailError } from "../usageLimitMessage";
|
||||
import { useSmoothStreamingText } from "./SmoothText";
|
||||
import type {
|
||||
MergedTool,
|
||||
+2
-2
@@ -27,9 +27,9 @@ import type { FC } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "utils/cn";
|
||||
import { parsePullRequestUrl } from "../../utils/pullRequest";
|
||||
import { useEmbedContext } from "../EmbedContext";
|
||||
import { PrStateIcon } from "../GitPanel";
|
||||
import { parsePullRequestUrl } from "../pullRequest";
|
||||
import { PrStateIcon } from "../GitPanel/GitPanel";
|
||||
|
||||
interface SidebarPanelState {
|
||||
showSidebarPanel: boolean;
|
||||
+2
-2
@@ -4,6 +4,8 @@ import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import { useFileAttachments } from "../hooks/useFileAttachments";
|
||||
import type { ChatDetailError } from "../utils/usageLimitMessage";
|
||||
import {
|
||||
AgentChatInput,
|
||||
type ChatMessageInputRef,
|
||||
@@ -30,8 +32,6 @@ import {
|
||||
} from "./AgentDetail/messageParsing";
|
||||
import { buildStreamTools } from "./AgentDetail/streamState";
|
||||
import type { ParsedMessageEntry } from "./AgentDetail/types";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import { useFileAttachments } from "./useFileAttachments";
|
||||
|
||||
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
|
||||
|
||||
+4
-4
@@ -7,6 +7,7 @@ import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||
import type { UrlTransform } from "streamdown";
|
||||
import { cn } from "utils/cn";
|
||||
import { pageTitle } from "utils/page";
|
||||
import type { ChatDetailError } from "../utils/usageLimitMessage";
|
||||
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
|
||||
import {
|
||||
selectChatStatus,
|
||||
@@ -19,10 +20,9 @@ import {
|
||||
ChatConversationSkeleton,
|
||||
RightPanelSkeleton,
|
||||
} from "./AgentsSkeletons";
|
||||
import { GitPanel } from "./GitPanel";
|
||||
import { RightPanel } from "./RightPanel";
|
||||
import { SidebarTabView } from "./SidebarTabView";
|
||||
import type { ChatDetailError } from "./usageLimitMessage";
|
||||
import { GitPanel } from "./GitPanel/GitPanel";
|
||||
import { RightPanel } from "./RightPanel/RightPanel";
|
||||
import { SidebarTabView } from "./Sidebar/SidebarTabView";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { Spinner } from "components/Spinner/Spinner";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatProviderLabel } from "../modelOptions";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import { normalizeProvider, readOptionalString } from "./helpers";
|
||||
import { ModelsSection } from "./ModelsSection";
|
||||
import { ProvidersSection } from "./ProvidersSection";
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ChevronLeftIcon, InfoIcon } from "lucide-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 { readOptionalString } from "./helpers";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatProviderLabel } from "../modelOptions";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import { normalizeProvider } from "./helpers";
|
||||
|
||||
const providerIconMap: Record<string, string> = {
|
||||
+4
-4
@@ -12,9 +12,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import type { DiffStyle } from "./DiffViewer";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import {
|
||||
annotationLineForBox,
|
||||
annotationSideForBox,
|
||||
@@ -22,7 +19,10 @@ import {
|
||||
commentBoxFromRange,
|
||||
contentRangeForBox,
|
||||
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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { DiffStatBadge } from "../DiffViewer/DiffStats";
|
||||
|
||||
const badgeMeta: Meta<typeof DiffStatBadge> = {
|
||||
title: "pages/AgentsPage/DiffStatBadge",
|
||||
+3
-3
@@ -2,9 +2,9 @@ import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, waitFor } from "storybook/test";
|
||||
import type { DiffStyle } from "./DiffViewer";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { InlinePromptInput } from "./RemoteDiffPanel";
|
||||
import type { DiffStyle } from "../DiffViewer/DiffViewer";
|
||||
import { DiffViewer } from "../DiffViewer/DiffViewer";
|
||||
import { InlinePromptInput } from "../DiffViewer/RemoteDiffPanel";
|
||||
|
||||
// biome-ignore format: raw diff string must preserve exact whitespace
|
||||
const sampleDiff = [
|
||||
+1
-1
@@ -24,7 +24,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { changeColor, changeLabel } from "./diffColors";
|
||||
import { changeColor, changeLabel } from "../../utils/diffColors";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Public interface
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import type { FC, RefObject } from "react";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { CommentableDiffViewer } from "./CommentableDiffViewer";
|
||||
import type { DiffStyle } from "./DiffViewer";
|
||||
import type { ChatMessageInputRef } from "../AgentChatInput";
|
||||
import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
|
||||
import type { DiffStyle } from "../DiffViewer/DiffViewer";
|
||||
|
||||
interface LocalDiffPanelProps {
|
||||
repo: WorkspaceAgentRepoChanges;
|
||||
+6
-6
@@ -14,13 +14,13 @@ import {
|
||||
import { type FC, type RefObject, useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { CommentableDiffViewer } from "./CommentableDiffViewer";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import type { DiffStyle } from "./DiffViewer";
|
||||
import { parsePullRequestUrl } from "./pullRequest";
|
||||
import { parsePullRequestUrl } from "../../utils/pullRequest";
|
||||
import type { ChatMessageInputRef } from "../AgentChatInput";
|
||||
import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
|
||||
import { DiffStatBadge } from "../DiffViewer/DiffStats";
|
||||
import type { DiffStyle } from "../DiffViewer/DiffViewer";
|
||||
|
||||
export { InlinePromptInput } from "./CommentableDiffViewer";
|
||||
export { InlinePromptInput } from "../DiffViewer/CommentableDiffViewer";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// PR state badge
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractDiffContent } from "./CommentableDiffViewer";
|
||||
import { extractDiffContent } from "../DiffViewer/CommentableDiffViewer";
|
||||
|
||||
function parse(diffStr: string) {
|
||||
return parsePatchFiles(diffStr).flatMap((p) => p.files);
|
||||
+9
-5
@@ -19,11 +19,15 @@ import {
|
||||
} from "lucide-react";
|
||||
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { type DiffStyle, loadDiffStyle, saveDiffStyle } from "./DiffViewer";
|
||||
import { LocalDiffPanel } from "./LocalDiffPanel";
|
||||
import { RemoteDiffPanel } from "./RemoteDiffPanel";
|
||||
import type { ChatMessageInputRef } from "../AgentChatInput";
|
||||
import { DiffStatBadge } from "../DiffViewer/DiffStats";
|
||||
import {
|
||||
type DiffStyle,
|
||||
loadDiffStyle,
|
||||
saveDiffStyle,
|
||||
} from "../DiffViewer/DiffViewer";
|
||||
import { LocalDiffPanel } from "../DiffViewer/LocalDiffPanel";
|
||||
import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
|
||||
|
||||
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { InlinePromptInput } from "./RemoteDiffPanel";
|
||||
import { InlinePromptInput } from "../DiffViewer/RemoteDiffPanel";
|
||||
|
||||
const meta: Meta<typeof InlinePromptInput> = {
|
||||
title: "pages/AgentsPage/InlinePromptInput",
|
||||
+1
-1
@@ -21,7 +21,7 @@ import type { FC } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { PrStateIcon } from "./GitPanel";
|
||||
import { PrStateIcon } from "./GitPanel/GitPanel";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user