From 1ba54e2ca61c6b20884bbd6dbe090e0682426ff1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 20 May 2026 20:06:54 +0700 Subject: [PATCH] chore: cleanup structure of left sidebar (#25528) This cleanups up the folder structure and breaks up files into a more atomic structure. No functional changes should exist here. --- .../pages/AgentsPage/AgentChatPageView.tsx | 4 +- .../pages/AgentsPage/AgentSettingsPage.tsx | 2 +- .../AgentsPage/AgentsPageView.stories.tsx | 4 +- site/src/pages/AgentsPage/AgentsPageView.tsx | 12 +- .../AgentsPage/components/AgentsSkeletons.tsx | 2 +- .../ChatElements/tools/utils.test.ts | 2 +- .../ChatsSidebar.stories.tsx} | 13 +- .../ChatsSidebar.test.tsx} | 57 +- .../components/ChatsSidebar/ChatsSidebar.tsx | 156 ++ .../ResizableChatsSidebarFrame.tsx} | 6 +- .../ChatsSidebar/chats/ChatSectionHeader.tsx | 51 + .../ChatsSidebar/chats/ChatsPanel.tsx | 491 +++++ .../ChatsSidebar/chats/LoadMoreSentinel.tsx | 44 + .../ChatsSidebar/chats/UserSidebarFooter.tsx | 53 + .../dialogs}/RenameChatDialog.tsx | 0 .../components/ChatsSidebar/dialogs/index.ts | 1 + .../filters}/FilterDropdown.stories.tsx | 0 .../filters}/FilterDropdown.tsx | 0 .../ChatsSidebar/settings/SettingsNavItem.tsx | 105 + .../ChatsSidebar/settings/SettingsPanel.tsx | 218 +++ .../sidebarView.test.ts} | 2 +- .../components/ChatsSidebar/sidebarView.ts | 47 + .../{Sidebar => ChatsSidebar}/sidebarWidth.ts | 2 +- .../tabs}/SidebarTabView.stories.tsx | 0 .../tabs}/SidebarTabView.tsx | 16 +- .../tabs}/getEffectiveTabId.test.ts | 0 .../tabs}/getEffectiveTabId.ts | 11 - .../ChatsSidebar/tree/ChatTreeContext.tsx | 39 + .../ChatsSidebar/tree/ChatTreeNode.tsx | 394 ++++ .../tree/SortableChatTreeNode.tsx | 48 + .../components/ChatsSidebar/tree/chatTree.ts | 130 ++ .../ChatsSidebar/tree/modelDisplayName.ts | 55 + .../ChatsSidebar/tree/statusConfig.ts | 60 + .../components/Sidebar/AgentsSidebar.tsx | 1733 ----------------- 34 files changed, 1945 insertions(+), 1813 deletions(-) rename site/src/pages/AgentsPage/components/{Sidebar/AgentsSidebar.stories.tsx => ChatsSidebar/ChatsSidebar.stories.tsx} (99%) rename site/src/pages/AgentsPage/components/{Sidebar/AgentsSidebar.test.tsx => ChatsSidebar/ChatsSidebar.test.tsx} (93%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx rename site/src/pages/AgentsPage/components/{Sidebar/ResizableAgentsSidebarFrame.tsx => ChatsSidebar/ResizableChatsSidebarFrame.tsx} (96%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatSectionHeader.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/chats/LoadMoreSentinel.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/dialogs}/RenameChatDialog.tsx (100%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/filters}/FilterDropdown.stories.tsx (100%) rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/filters}/FilterDropdown.tsx (100%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx rename site/src/pages/AgentsPage/components/{Sidebar/sidebarViewFromPath.test.ts => ChatsSidebar/sidebarView.test.ts} (97%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar}/sidebarWidth.ts (96%) rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/tabs}/SidebarTabView.stories.tsx (100%) rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/tabs}/SidebarTabView.tsx (92%) rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/tabs}/getEffectiveTabId.test.ts (100%) rename site/src/pages/AgentsPage/components/{Sidebar => ChatsSidebar/tabs}/getEffectiveTabId.ts (52%) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeContext.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/SortableChatTreeNode.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/chatTree.ts create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/modelDisplayName.ts create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts delete mode 100644 site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index ca8c2dbce9..532a3fb9cc 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -32,12 +32,12 @@ import { DesktopPanelContext } from "./components/ChatElements/tools/DesktopPane import type { PendingAttachment } from "./components/ChatPageContent"; import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; import { ChatScrollContainer } from "./components/ChatScrollContainer"; +import { getEffectiveTabId } from "./components/ChatsSidebar/tabs/getEffectiveTabId"; +import { SidebarTabView } from "./components/ChatsSidebar/tabs/SidebarTabView"; import { ChatTopBar } from "./components/ChatTopBar"; import { GitPanel } from "./components/GitPanel/GitPanel"; import { DebugPanel } from "./components/RightPanel/DebugPanel/DebugPanel"; import { RightPanel } from "./components/RightPanel/RightPanel"; -import { getEffectiveTabId } from "./components/Sidebar/getEffectiveTabId"; -import { SidebarTabView } from "./components/Sidebar/SidebarTabView"; import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon"; import { TerminalPanel } from "./components/TerminalPanel"; import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext"; diff --git a/site/src/pages/AgentsPage/AgentSettingsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsPage.tsx index b176a6c6f8..2363c4eaef 100644 --- a/site/src/pages/AgentsPage/AgentSettingsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsPage.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { Outlet, useLocation } from "react-router"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { AgentPageHeader } from "./components/AgentPageHeader"; -import { sidebarViewFromPath } from "./components/Sidebar/AgentsSidebar"; +import { sidebarViewFromPath } from "./components/ChatsSidebar/sidebarView"; const AgentSettingsPage: FC = () => { const location = useLocation(); diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 28ab040a5c..562f7a973a 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -38,7 +38,6 @@ import AgentSettingsPage from "./AgentSettingsPage"; import AgentSettingsSpendPage from "./AgentSettingsSpendPage"; import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView"; import type { ModelSelectorOption } from "./components/ChatElements"; -import { ChatTopBar } from "./components/ChatTopBar"; import { clampLeftSidebarWidth, getLeftSidebarMaxWidth, @@ -46,7 +45,8 @@ import { LEFT_SIDEBAR_KEYBOARD_RESIZE_STEP, LEFT_SIDEBAR_MIN_WIDTH, LEFT_SIDEBAR_STORAGE_KEY, -} from "./components/Sidebar/sidebarWidth"; +} from "./components/ChatsSidebar/sidebarWidth"; +import { ChatTopBar } from "./components/ChatTopBar"; const defaultModelConfigID = "model-config-1"; diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 272caba6ec..e9ccf12e0d 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -5,11 +5,11 @@ import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import type { ModelSelectorOption } from "./components/ChatElements"; import { - AgentsSidebar, + ChatsSidebar, isSettingsView, sidebarViewFromPath, -} from "./components/Sidebar/AgentsSidebar"; -import { ResizableAgentsSidebarFrame } from "./components/Sidebar/ResizableAgentsSidebarFrame"; +} from "./components/ChatsSidebar/ChatsSidebar"; +import { ResizableChatsSidebarFrame } from "./components/ChatsSidebar/ResizableChatsSidebarFrame"; import type { ChatDetailError } from "./utils/usageLimitMessage"; export interface AgentsOutletContext { @@ -162,7 +162,7 @@ export const AgentsPageView: FC = ({ className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row" > {pageTitle("Agents")} - = ({ isSidebarCollapsed && "sm:hidden", )} > - = ({ isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled} isAdmin={isAgentsAdmin} /> - +
{ const spy = vi.spyOn(console, "error").mockImplementation(() => {}); try { const diff = buildEditDiff( - "/home/coder/coder/site/src/pages/AgentsPage/AgentsSidebar.tsx", + "/home/coder/coder/site/src/pages/AgentsPage/components/ChatSidebar/ChatsSidebar.tsx", [ { search: "const a = 1;", replace: "const a = 2;" }, { search: "const b = 3;", replace: "const b = 4;" }, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx similarity index 99% rename from site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index e8d7af2867..f98657cccd 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -12,7 +12,7 @@ import { withDashboardProvider, } from "#/testHelpers/storybook"; import type { ModelSelectorOption } from "../ChatElements"; -import { AgentsSidebar } from "./AgentsSidebar"; +import { ChatsSidebar } from "./ChatsSidebar"; // Probe element used by the archived-filter preservation story to surface the // search string of whatever child route the sidebar's NavLink ends up at. @@ -59,7 +59,6 @@ const buildChat = (overrides: Partial = {}): Chat => ({ id: "chat-default", organization_id: "test-org-id", owner_id: "owner-1", - owner_username: "owner", title: "Agent", status: "completed", last_model_config_id: defaultModelConfigs[0].id, @@ -93,9 +92,9 @@ const settingsRouting = [ ...{ path: string; useStoryElement: boolean }[], ]; -const meta: Meta = { - title: "pages/AgentsPage/AgentsSidebar", - component: AgentsSidebar, +const meta: Meta = { + title: "pages/AgentsPage/ChatsSidebar", + component: ChatsSidebar, decorators: [withAuthProvider, withDashboardProvider], args: { chatErrorReasons: {}, @@ -125,7 +124,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const ChatWithTurnSummary: Story = { args: { @@ -226,7 +225,7 @@ export const StaleTurnSummaryAfterStreamingIsSuppressed: Story = { > advance - +
); }, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx similarity index 93% rename from site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx index 67869f7f12..c04a96bfc8 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.test.tsx @@ -17,7 +17,7 @@ import { MockUserOwner, } from "#/testHelpers/entities"; import themes, { DEFAULT_THEME } from "#/theme"; -import { AgentsSidebar } from "./AgentsSidebar"; +import { ChatsSidebar } from "./ChatsSidebar"; // ---- IntersectionObserver mock ---- @@ -57,7 +57,6 @@ const buildChat = (overrides: Partial = {}): Chat => ({ id: "chat-default", organization_id: "test-org-id", owner_id: "owner-1", - owner_username: "owner", title: "Agent", status: "completed", last_model_config_id: "model-1", @@ -105,7 +104,7 @@ const Wrapper: FC = ({ children }) => { ); }; -const defaultProps: React.ComponentProps = { +const defaultProps: React.ComponentProps = { chats: [buildChat({ id: "chat-1", title: "Chat One" })], chatErrorReasons: {}, modelOptions: [], @@ -124,14 +123,14 @@ const defaultProps: React.ComponentProps = { // ---- Tests ---- -describe("AgentsSidebar archived filter", () => { +describe("ChatsSidebar archived filter", () => { it("calls the filter change callback from the dropdown", async () => { const user = userEvent.setup(); const onArchivedFilterChange = vi.fn(); render( - @@ -150,7 +149,7 @@ describe("AgentsSidebar archived filter", () => { render( - { }); }); -describe("AgentsSidebar load-more behavior", () => { +describe("ChatsSidebar load-more behavior", () => { beforeEach(() => { observerCallback = null; observeCount = 0; @@ -180,7 +179,7 @@ describe("AgentsSidebar load-more behavior", () => { const onLoadMore = vi.fn(); render( - + , ); @@ -195,7 +194,7 @@ describe("AgentsSidebar load-more behavior", () => { const onLoadMore = vi.fn(); render( - { const onLoadMore1 = vi.fn(); const { rerender } = render( - + , ); @@ -226,7 +225,7 @@ describe("AgentsSidebar load-more behavior", () => { const onLoadMore2 = vi.fn(); rerender( - + , ); @@ -245,7 +244,7 @@ describe("AgentsSidebar load-more behavior", () => { const onLoadMore = vi.fn(); const { rerender } = render( - + , ); @@ -260,7 +259,7 @@ describe("AgentsSidebar load-more behavior", () => { for (let i = 0; i < 10; i++) { rerender( - onLoadMore()} @@ -278,7 +277,7 @@ describe("AgentsSidebar load-more behavior", () => { const onLoadMore = vi.fn(); const { rerender } = render( - { // Fetch completes. rerender( - { const onLoadMore = vi.fn(); const { rerender } = render( - { // Start fetching — observer is torn down. rerender( - { // an initial entry that detects the still-visible sentinel. rerender( - { const onLoadMore = vi.fn(); render( - { }); }); -describe("AgentsSidebar model display names", () => { +describe("ChatsSidebar model display names", () => { it("uses the chat model config ID to pick the correct duplicate model label", () => { const modelOptions = [ { @@ -419,7 +418,7 @@ describe("AgentsSidebar model display names", () => { const { getByText, queryByText } = render( - { it("falls back to legacy provider/model matching when no config ID match exists", () => { const { getByText } = render( - { it("shows Default model when last_model_config_id is a nil UUID", () => { const { getByText } = render( - { it("shows model name when last_model_config_id matches a config", () => { const { getByText, queryByText } = render( - { }); }); -describe("AgentsSidebar subtitles", () => { +describe("ChatsSidebar subtitles", () => { const modelOptions = [ { id: "model-1", @@ -538,7 +537,7 @@ describe("AgentsSidebar subtitles", () => { it("shows the last turn summary when present and no error exists", () => { render( - { it("shows the error when both error and last turn summary exist", () => { render( - { it("falls back to the model name when no last turn summary exists", () => { render( - { it("falls back to the model name when the last turn summary is blank", () => { render( - ; + modelOptions: readonly ModelSelectorOption[]; + modelConfigs: readonly ChatModelConfig[]; + onArchiveAgent: (chatId: string) => void; + onUnarchiveAgent: (chatId: string) => void; + onArchiveAndDeleteWorkspace: (chatId: string, workspaceId: string) => void; + onPinAgent: (chatId: string) => void; + onUnpinAgent: (chatId: string) => void; + onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; + onRenameTitle?: (chatId: string, title: string) => Promise; + onProposeTitle?: (chatId: string) => Promise; + onBeforeNewAgent?: () => void; + isCreating: boolean; + isArchiving?: boolean; + archivingChatId?: string | null; + regeneratingTitleChatIds: readonly string[]; + isLoading?: boolean; + loadError?: unknown; + onRetryLoad?: () => void; + hasNextPage?: boolean; + onLoadMore?: () => void; + isFetchingNextPage?: boolean; + archivedFilter: "active" | "archived"; + onArchivedFilterChange?: (filter: "active" | "archived") => void; + onCollapse?: () => void; + isPersonalModelOverridesEnabled?: boolean; + isAdmin?: boolean; +} + +export const ChatsSidebar: FC = (props) => { + const { + chats, + chatErrorReasons, + modelOptions, + modelConfigs, + onArchiveAgent, + onUnarchiveAgent, + onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, + onReorderPinnedAgent, + onRenameTitle, + onProposeTitle, + onBeforeNewAgent, + isCreating, + isArchiving = false, + archivingChatId = null, + regeneratingTitleChatIds, + isLoading = false, + loadError, + onRetryLoad, + hasNextPage, + onLoadMore, + isFetchingNextPage, + archivedFilter, + onArchivedFilterChange, + onCollapse, + isPersonalModelOverridesEnabled = false, + isAdmin = false, + } = props; + const { agentId, chatId } = useParams<{ + agentId?: string; + chatId?: string; + }>(); + const activeChatId = agentId ?? chatId; + const location = useLocation(); + const sidebarView = sidebarViewFromPath(location.pathname); + const isSettingsPanel = isSettingsView(sidebarView); + const isFallbackToUserPanel = + sidebarView.panel === "settings-admin" && !isAdmin; + const settingsPanel = + sidebarView.panel === "settings-admin" && isAdmin + ? "settings-admin" + : "settings"; + const settingsSection = + isSettingsPanel && !isFallbackToUserPanel ? sidebarView.section : undefined; + const providerConfigsQuery = useQuery({ + ...userChatProviderConfigs(), + enabled: isSettingsPanel && !isAdmin, + }); + const isApiKeysSection = isSettingsPanel && settingsSection === "api-keys"; + const showApiKeysItem = + isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length); + const [chatPendingRename, setChatPendingRename] = useState(null); + + return ( +
+ + + {onRenameTitle && ( + { + if (!open) setChatPendingRename(null); + }} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx similarity index 96% rename from site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx index 0fe6a60ec9..4dcef6532f 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/ResizableAgentsSidebarFrame.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx @@ -18,15 +18,15 @@ import { persistLeftSidebarWidth, } from "./sidebarWidth"; -interface ResizableAgentsSidebarFrameProps { +interface ResizableChatsSidebarFrameProps { children: ReactNode; className?: string; } -export const ResizableAgentsSidebarFrame = ({ +export const ResizableChatsSidebarFrame = ({ children, className, -}: ResizableAgentsSidebarFrameProps) => { +}: ResizableChatsSidebarFrameProps) => { const [width, setWidth] = useState(loadPersistedLeftSidebarWidth); const maxWidth = getLeftSidebarMaxWidth(); const isDragging = useRef(false); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatSectionHeader.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatSectionHeader.tsx new file mode 100644 index 0000000000..d06b1b9f0b --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatSectionHeader.tsx @@ -0,0 +1,51 @@ +import { ChevronDownIcon } from "lucide-react"; +import type { FC } from "react"; +import { cn } from "#/utils/cn"; + +export const PINNED_SECTION_KEY = "Pinned"; + +export const getSectionToggleTestId = (sectionKey: string) => + `agents-section-toggle-${sectionKey.replaceAll(" ", "-")}`; + +interface ChatSectionHeaderProps { + readonly label: string; + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly testId: string; +} + +export const ChatSectionHeader: FC = ({ + label, + count, + expanded, + onToggle, + testId, +}) => { + const actionLabel = expanded ? "Collapse" : "Expand"; + return ( +
+ +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx new file mode 100644 index 0000000000..e1c23d0d57 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -0,0 +1,491 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { PanelLeftCloseIcon, SettingsIcon, SquarePenIcon } from "lucide-react"; +import { type FC, useEffect, useRef, useState } from "react"; +import { Link, type Location, NavLink } from "react-router"; +import type { Chat, ChatModelConfig } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge"; +import { ProductLogo } from "#/components/Icons/ProductLogo"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { cn } from "#/utils/cn"; +import { getTimeGroup, TIME_GROUPS } from "../../../utils/timeGroups"; +import type { ModelSelectorOption } from "../../ChatElements"; +import { FilterDropdown } from "../filters/FilterDropdown"; +import { SettingsNavItem } from "../settings/SettingsNavItem"; +import { + ChatTreeContext, + type ChatTreeContextValue, +} from "../tree/ChatTreeContext"; +import { ChatTreeNode } from "../tree/ChatTreeNode"; +import { + buildChatTree, + type ChatTree, + collectVisibleChatIDs, +} from "../tree/chatTree"; +import { SortableChatTreeNode } from "../tree/SortableChatTreeNode"; +import { + ChatSectionHeader, + getSectionToggleTestId, + PINNED_SECTION_KEY, +} from "./ChatSectionHeader"; +import { LoadMoreSentinel } from "./LoadMoreSentinel"; +import { UserSidebarFooter } from "./UserSidebarFooter"; + +interface ChatsPanelProps { + readonly chats: readonly Chat[]; + readonly chatErrorReasons: Record; + readonly modelOptions: readonly ModelSelectorOption[]; + readonly modelConfigs: readonly ChatModelConfig[]; + readonly onArchiveAgent: (chatId: string) => void; + readonly onUnarchiveAgent: (chatId: string) => void; + readonly onArchiveAndDeleteWorkspace: ( + chatId: string, + workspaceId: string, + ) => void; + readonly onPinAgent: (chatId: string) => void; + readonly onUnpinAgent: (chatId: string) => void; + readonly onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; + readonly onBeforeNewAgent?: () => void; + readonly onOpenRenameDialog?: (chat: Chat) => void; + readonly isCreating: boolean; + readonly isArchiving: boolean; + readonly archivingChatId: string | null; + readonly regeneratingTitleChatIds: readonly string[]; + readonly isLoading: boolean; + readonly loadError?: unknown; + readonly onRetryLoad?: () => void; + readonly hasNextPage?: boolean; + readonly onLoadMore?: () => void; + readonly isFetchingNextPage?: boolean; + readonly archivedFilter: "active" | "archived"; + readonly onArchivedFilterChange?: (filter: "active" | "archived") => void; + readonly onCollapse?: () => void; + readonly activeChatId: string | undefined; + readonly isSettingsPanel: boolean; + readonly isChatsActive: boolean; + readonly location: Location; +} + +export const ChatsPanel: FC = ({ + chats, + chatErrorReasons, + modelOptions, + modelConfigs, + onArchiveAgent, + onUnarchiveAgent, + onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, + onReorderPinnedAgent, + onBeforeNewAgent, + onOpenRenameDialog, + isCreating, + isArchiving, + archivingChatId, + regeneratingTitleChatIds, + isLoading, + loadError, + onRetryLoad, + hasNextPage, + onLoadMore, + isFetchingNextPage, + archivedFilter, + onArchivedFilterChange, + onCollapse, + activeChatId, + isSettingsPanel, + isChatsActive, + location, +}) => { + const normalizedSearch = ""; + const [expandedById, setExpandedById] = useState>({}); + const [collapsedSections, setCollapsedSections] = useState< + Record + >({}); + + const chatTree = buildChatTree(chats); + const chatById = chatTree.chatById; + const visibleChatIDs = collectVisibleChatIDs({ + chats, + search: normalizedSearch, + tree: chatTree, + }); + const visibleRootIDs = chatTree.rootIds.filter((chatID) => + visibleChatIDs.has(chatID), + ); + + const pinnedChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter((chat): chat is Chat => (chat?.pin_order ?? 0) > 0) + .sort((a, b) => a.pin_order - b.pin_order); + + // Local override for pinned order during drag. Applied + // synchronously so there's no flash between the dnd-kit + // transform clearing and the server data arriving. + const [localPinOrder, setLocalPinOrder] = useState(null); + + // Clear the local override when fresh data arrives from + // the server (the mutation's onSettled invalidates queries). + const chatsRef = useRef(chats); + useEffect(() => { + if (chats !== chatsRef.current) { + chatsRef.current = chats; + setLocalPinOrder(null); + } + }, [chats]); + + const sortedPinnedChats = localPinOrder + ? localPinOrder + .map((id) => pinnedChats.find((c) => c.id === id)) + .filter((c) => c !== undefined) + : pinnedChats; + + const pinnedChatIds = sortedPinnedChats.map((chat) => chat.id); + const lastDragEndedAtRef = useRef(0); + const pinnedContainerRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + const container = pinnedContainerRef.current; + const target = e.target; + if ( + container && + target instanceof Node && + container.contains(target) && + performance.now() - lastDragEndedAtRef.current < 300 + ) { + e.preventDefault(); + } + }; + document.addEventListener("click", handler, true); + return () => document.removeEventListener("click", handler, true); + }, []); + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + lastDragEndedAtRef.current = performance.now(); + if (!over || active.id === over.id) return; + const activeId = String(active.id); + const overId = String(over.id); + const oldIndex = pinnedChatIds.indexOf(activeId); + const newIndex = pinnedChatIds.indexOf(overId); + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = arrayMove(pinnedChatIds, oldIndex, newIndex); + setLocalPinOrder(reordered); + onReorderPinnedAgent?.(activeId, newIndex + 1); + }; + + // Auto-expand ancestors of the active chat so it's always visible. + // Only runs when activeChatId changes, not on every parentById + // recalculation, so user-initiated collapse is preserved. + const parentByIdRef = useRef(chatTree.parentById); + useEffect(() => { + parentByIdRef.current = chatTree.parentById; + }); + useEffect(() => { + if (!activeChatId) { + return; + } + const parentById = parentByIdRef.current; + const toExpand: string[] = []; + let cursor = parentById.get(activeChatId); + const seen = new Set(); + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + toExpand.push(cursor); + cursor = parentById.get(cursor); + } + if (toExpand.length > 0) { + setExpandedById((prev) => { + if (toExpand.every((id) => prev[id])) { + return prev; + } + const next = { ...prev }; + for (const id of toExpand) { + next[id] = true; + } + return next; + }); + } + }, [activeChatId]); + + const toggleExpanded = (chatID: string) => { + setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] })); + }; + const toggleSection = (sectionKey: string) => { + setCollapsedSections((prev) => ({ + ...prev, + [sectionKey]: !prev[sectionKey], + })); + }; + + const chatTreeCtx: ChatTreeContextValue = { + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + expandedById, + modelOptions, + modelConfigs, + chatErrorReasons, + activeChatId, + isArchiving, + archivingChatId, + regeneratingTitleChatIds, + toggleExpanded, + onArchiveAgent, + onUnarchiveAgent, + onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, + onOpenRenameDialog, + }; + + return ( +
+
+
+
+ + + + +
+
+ + {onCollapse && ( + + )} +
+
+ +
+
+ +
+ {loadError ? ( +
+ + {onRetryLoad && ( + + )} +
+ ) : isLoading ? ( + <> + +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ + ) : ( + + {visibleRootIDs.length === 0 ? ( +
+

+ {normalizedSearch + ? "No matching agents" + : archivedFilter === "archived" + ? "No archived agents" + : "No agents yet"} +

+ +
+ ) : ( +
+
+ +
+ {pinnedChats.length > 0 && ( +
+ toggleSection(PINNED_SECTION_KEY)} + testId={getSectionToggleTestId(PINNED_SECTION_KEY)} + /> + {!collapsedSections[PINNED_SECTION_KEY] && ( + + +
+ {sortedPinnedChats.map((chat) => ( + + ))} +
+
+
+ )} +
+ )} + {TIME_GROUPS.map((group) => { + const groupChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter( + (chat): chat is Chat => + chat !== undefined && + getTimeGroup(chat.updated_at) === group && + chat.pin_order === 0, + ); + if (groupChats.length === 0) return null; + const isGroupExpanded = !collapsedSections[group]; + return ( +
+ toggleSection(group)} + testId={getSectionToggleTestId(group)} + /> + {isGroupExpanded && ( +
+ {groupChats.map((chat) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} + {(hasNextPage || isFetchingNextPage) && ( + + )} +
+ )} +
+
+
+ +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/LoadMoreSentinel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/LoadMoreSentinel.tsx new file mode 100644 index 0000000000..d0342c173d --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/LoadMoreSentinel.tsx @@ -0,0 +1,44 @@ +import { type FC, useEffect, useEffectEvent, useRef } from "react"; +import { Spinner } from "#/components/Spinner/Spinner"; + +export const LoadMoreSentinel: FC<{ + onLoadMore?: () => void; + isFetchingNextPage?: boolean; +}> = ({ onLoadMore, isFetchingNextPage }) => { + const sentinelRef = useRef(null); + const onLoadMoreEvent = useEffectEvent(() => { + onLoadMore?.(); + }); + + useEffect(() => { + // Don't observe while a fetch is in progress. When the + // fetch completes this effect re-runs, creating a fresh + // observer whose initial entry detects the sentinel if + // it's still visible, fixing the case where loaded items + // don't push the sentinel out of view and the previous + // observer never re-fires. + if (isFetchingNextPage) return; + + const el = sentinelRef.current; + if (!el) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + onLoadMoreEvent(); + } + }, + { threshold: 0 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [isFetchingNextPage]); + + return ( +
+ {isFetchingNextPage && ( + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx new file mode 100644 index 0000000000..2eb0ae1970 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { UserDropdownContent } from "#/modules/dashboard/Navbar/UserDropdown/UserDropdownContent"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { UsageIndicator } from "../../UsageIndicator"; + +export const UserSidebarFooter: FC = () => { + const { user, signOut } = useAuthenticated(); + const { appearance, buildInfo } = useDashboard(); + + return ( +
+
+ + + + + + link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> + + + +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/RenameChatDialog.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/RenameChatDialog.tsx diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts new file mode 100644 index 0000000000..6926559129 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/index.ts @@ -0,0 +1 @@ +export { RenameChatDialog } from "./RenameChatDialog"; diff --git a/site/src/pages/AgentsPage/components/Sidebar/FilterDropdown.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.stories.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/Sidebar/FilterDropdown.stories.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.stories.tsx diff --git a/site/src/pages/AgentsPage/components/Sidebar/FilterDropdown.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/Sidebar/FilterDropdown.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterDropdown.tsx diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx new file mode 100644 index 0000000000..894068baba --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx @@ -0,0 +1,105 @@ +import { ShieldIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +type SettingsNavItemProps = { + icon: FC<{ className?: string }>; + label: string; + active: boolean; + adminOnly?: boolean; + disabled?: boolean; + trailingIcon?: FC<{ className?: string }>; +} & ( + | { to: string; replace?: boolean; state?: unknown; onClick?: () => void } + | { to?: never; replace?: never; state?: never; onClick: () => void } +); + +const navItemClassName = (active: boolean, disabled: boolean | undefined) => + cn( + "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline", + active + ? "bg-surface-quaternary/25 text-content-primary font-medium" + : "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary", + disabled && "opacity-50 pointer-events-none", + ); + +const NavItemContent: FC<{ + icon: FC<{ className?: string }>; + label: string; + adminOnly?: boolean; + trailingIcon?: FC<{ className?: string }>; +}> = ({ icon: Icon, label, adminOnly, trailingIcon: TrailingIcon }) => ( + <> + + {label} + {(adminOnly || TrailingIcon) && ( + + {adminOnly && ( + + + + + + + Admin only + + )} + {TrailingIcon && } + + )} + +); + +export const SettingsNavItem: FC = ({ + icon, + label, + active, + adminOnly, + disabled, + trailingIcon, + ...rest +}) => { + if (rest.to != null) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx new file mode 100644 index 0000000000..3c211b3e42 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -0,0 +1,218 @@ +import { + ArrowLeftIcon, + BotIcon, + BoxesIcon, + ChevronRightIcon, + CoinsIcon, + FlaskConicalIcon, + KeyIcon, + LayoutTemplateIcon, + PanelLeftCloseIcon, + PlugIcon, + ReceiptTextIcon, + RefreshCwIcon, + ServerIcon, + Settings2Icon, + ShrinkIcon, + UserIcon, +} from "lucide-react"; +import type { FC } from "react"; +import { Link, type Location } from "react-router"; +import { Button } from "#/components/Button/Button"; +import { cn } from "#/utils/cn"; +import { SettingsNavItem } from "./SettingsNavItem"; + +interface SettingsPanelProps { + readonly isSettingsPanel: boolean; + readonly settingsPanel: "settings" | "settings-admin"; + readonly settingsSection: string | undefined; + readonly showApiKeysItem: boolean; + readonly isPersonalModelOverridesEnabled: boolean; + readonly isAdmin: boolean; + readonly location: Location; + readonly onCollapse?: () => void; +} + +export const SettingsPanel: FC = ({ + isSettingsPanel, + settingsPanel, + settingsSection, + showApiKeysItem, + isPersonalModelOverridesEnabled, + isAdmin, + location, + onCollapse, +}) => { + const subNavTitle = + settingsPanel === "settings-admin" ? "Manage Agents" : "Settings"; + + return ( +
+
+
+ + {subNavTitle} + + +
+ {onCollapse && ( + + )} +
+
+ {settingsPanel === "settings" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.test.ts similarity index 97% rename from site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts rename to site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.test.ts index 9113b9f30d..3bfc68fcae 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/sidebarViewFromPath.test.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isSettingsView, sidebarViewFromPath } from "./AgentsSidebar"; +import { isSettingsView, sidebarViewFromPath } from "./sidebarView"; describe("sidebarViewFromPath", () => { it("returns chats for the agents index", () => { diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts new file mode 100644 index 0000000000..7da6fd0258 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarView.ts @@ -0,0 +1,47 @@ +type SidebarView = + | { panel: "chats" } + | { panel: "settings"; section: string | undefined } + | { panel: "settings-admin"; section: string | undefined } + | { panel: "analytics" }; + +const ADMIN_SETTINGS_SECTIONS = new Set([ + "agents", + "templates", + "providers", + "models", + "mcp-servers", + "spend", + "insights", + "instructions", + "experiments", + "lifecycle", +]); + +/** + * Derive the current sidebar view from the URL pathname. + */ +export function sidebarViewFromPath(pathname: string): SidebarView { + if (pathname.startsWith("/agents/analytics")) { + return { panel: "analytics" }; + } + const settingsMatch = pathname.match(/^\/agents\/settings(?:\/([^/]+))?/); + if (settingsMatch) { + const section = settingsMatch[1]; + if (section === "admin") { + return { panel: "settings-admin", section: undefined }; + } + return { + panel: ADMIN_SETTINGS_SECTIONS.has(section ?? "") + ? "settings-admin" + : "settings", + section, + }; + } + return { panel: "chats" }; +} + +export function isSettingsView( + view: SidebarView, +): view is Extract { + return view.panel === "settings" || view.panel === "settings-admin"; +} diff --git a/site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarWidth.ts similarity index 96% rename from site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts rename to site/src/pages/AgentsPage/components/ChatsSidebar/sidebarWidth.ts index 48cfbe5048..0b6f6179ab 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/sidebarWidth.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/sidebarWidth.ts @@ -11,7 +11,7 @@ export function getLeftSidebarMaxWidth(): number { LEFT_SIDEBAR_MIN_WIDTH, Math.min( LEFT_SIDEBAR_MAX_WIDTH, - Math.floor(window.innerWidth * LEFT_SIDEBAR_MAX_WIDTH_RATIO), + Math.floor(innerWidth * LEFT_SIDEBAR_MAX_WIDTH_RATIO), ), ); } diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx similarity index 100% rename from site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.stories.tsx diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx similarity index 92% rename from site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx rename to site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx index cff9fcdd68..ede6b4d8e1 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/SidebarTabView.tsx @@ -10,7 +10,7 @@ import type { ReactNode } from "react"; import { type FC, useEffect, useId, useRef, useState } from "react"; import { Button } from "#/components/Button/Button"; import { cn } from "#/utils/cn"; -import { DesktopPanel } from "../RightPanel/DesktopPanel"; +import { DesktopPanel } from "../../RightPanel/DesktopPanel"; /** A single tab definition for the sidebar panel. */ export interface SidebarTab { @@ -75,13 +75,9 @@ function useTabScroll() { setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }; - // Initial check. update(); - - // Re-check on scroll. el.addEventListener("scroll", update, { passive: true }); - // Re-check when the container or its children resize. const ro = new ResizeObserver(update); ro.observe(el); @@ -122,8 +118,6 @@ export const SidebarTabView: FC = ({ }) => { const tabIdPrefix = useId(); - // Unified list of panels for rendering. Includes the desktop - // tab when available so we don't need to special-case it. const allPanels: { id: string; content: ReactNode }[] = tabs.map((t) => ({ id: t.id, content: t.content, @@ -151,7 +145,6 @@ export const SidebarTabView: FC = ({ if (tabs.length === 0 && !desktopChatId) { return (
- {/* Tab bar – always visible for the expand button. */}
= ({ return (
- {/* Tab bar */}
- {/* Back button (mobile), placed before the tab strip */} {onClose && ( )} - {/* Sidebar toggle – only when expanded and sidebar is collapsed */} {isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && ( )} - {/* Scrollable tab strip with overlay chevrons */}
{canScrollLeft && ( )}
- {/* Center: chat title when expanded */} {isExpanded && chatTitle && (
@@ -308,7 +296,6 @@ export const SidebarTabView: FC = ({
)} - {/* Expand/collapse (desktop only) */}
- {/* Tab panels – all stay mounted, only the active one visible. */} {allPanels.map((panel) => { const isActive = effectiveTabId === panel.id; return ( diff --git a/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/getEffectiveTabId.test.ts similarity index 100% rename from site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.test.ts rename to site/src/pages/AgentsPage/components/ChatsSidebar/tabs/getEffectiveTabId.test.ts diff --git a/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/getEffectiveTabId.ts similarity index 52% rename from site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts rename to site/src/pages/AgentsPage/components/ChatsSidebar/tabs/getEffectiveTabId.ts index 16d6baf310..456d85aa37 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/getEffectiveTabId.ts +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tabs/getEffectiveTabId.ts @@ -2,17 +2,6 @@ * Resolves which sidebar tab should be active given the set of * available tab IDs, the currently stored selection, and whether * the desktop chat tab is available. - * - * Precedence: - * 1. `activeTabId` when it matches a known tab. - * 2. The first entry in `tabIds` (ordered array, not a Set). - * 3. `"desktop"` when `desktopChatId` is truthy. - * 4. `null` (no valid tab available). - * - * AgentChatPageView owns this resolution so the parent-side gating - * (e.g. `TerminalPanel.isVisible`, `DebugPanel.isVisible`) and the - * child SidebarTabView's visual highlight always agree. The child - * receives the resolved value via the `effectiveTabId` prop. */ export function getEffectiveTabId( tabIds: readonly string[], diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeContext.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeContext.tsx new file mode 100644 index 0000000000..c12c6c3dd5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeContext.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext } from "react"; +import type { Chat, ChatModelConfig } from "#/api/typesGenerated"; +import type { ModelSelectorOption } from "../../ChatElements"; +import type { ChatTree } from "./chatTree"; + +export interface ChatTreeContextValue { + readonly chatTree: ChatTree; + readonly chatById: ReadonlyMap; + readonly visibleChatIDs: ReadonlySet; + readonly normalizedSearch: string; + readonly expandedById: Record; + readonly modelOptions: readonly ModelSelectorOption[]; + readonly modelConfigs: readonly ChatModelConfig[]; + readonly chatErrorReasons: Record; + readonly activeChatId: string | undefined; + readonly isArchiving: boolean; + readonly archivingChatId: string | null; + readonly regeneratingTitleChatIds: readonly string[]; + readonly toggleExpanded: (chatID: string) => void; + readonly onArchiveAgent: (chatId: string) => void; + readonly onUnarchiveAgent: (chatId: string) => void; + readonly onArchiveAndDeleteWorkspace: ( + chatId: string, + workspaceId: string, + ) => void; + readonly onPinAgent: (chatId: string) => void; + readonly onUnpinAgent: (chatId: string) => void; + readonly onOpenRenameDialog?: (chat: Chat) => void; +} + +export const ChatTreeContext = createContext(null); + +export function useChatTree(): ChatTreeContextValue { + const ctx = useContext(ChatTreeContext); + if (!ctx) { + throw new Error("useChatTree must be used within ChatTreeContext.Provider"); + } + return ctx; +} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx new file mode 100644 index 0000000000..0c723453af --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -0,0 +1,394 @@ +import { + ArchiveIcon, + ArchiveRestoreIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisVerticalIcon, + PinIcon, + PinOffIcon, + SquarePenIcon, + Trash2Icon, +} from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { NavLink, useLocation } from "react-router"; +import type { Chat } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "#/components/ContextMenu/ContextMenu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; +import { shortRelativeTime } from "#/utils/time"; +import { asNonEmptyString } from "../../ChatConversation/blockUtils"; +import { useChatTree } from "./ChatTreeContext"; +import { getParentChatID } from "./chatTree"; +import { getModelDisplayName } from "./modelDisplayName"; +import { + getChatDiffStatus, + getPRIconConfig, + getStatusConfig, +} from "./statusConfig"; + +interface ChatTreeNodeProps { + readonly chat: Chat; + readonly isChildNode: boolean; +} + +export const ChatTreeNode: FC = ({ chat, isChildNode }) => { + const location = useLocation(); + const { + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + expandedById, + modelOptions, + modelConfigs, + chatErrorReasons, + activeChatId, + isArchiving, + archivingChatId, + regeneratingTitleChatIds, + toggleExpanded, + onArchiveAgent, + onUnarchiveAgent, + onArchiveAndDeleteWorkspace, + onPinAgent, + onUnpinAgent, + onOpenRenameDialog, + } = useChatTree(); + const chatID = chat.id; + const isActiveChat = activeChatId === chatID; + const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) => + visibleChatIDs.has(childID), + ); + const hasChildren = childIDs.length > 0; + const isDelegated = Boolean(getParentChatID(chat)); + const isDelegatedExecuting = + isDelegated && (chat.status === "pending" || chat.status === "running"); + const modelName = getModelDisplayName( + chat.last_model_config_id, + modelConfigs, + modelOptions, + ); + const errorReason = + chat.status === "error" + ? chatErrorReasons[chat.id] || chat.last_error?.message || undefined + : undefined; + const lastTurnSummary = asNonEmptyString(chat.last_turn_summary); + const isStreaming = chat.status === "running" || chat.status === "pending"; + const streamingSubtitle = isStreaming ? `${modelName} streaming…` : undefined; + const staleTurnSummaryReleaseMs = 10_000; + const [streamingSummary, setStreamingSummary] = useState( + isStreaming ? lastTurnSummary : undefined, + ); + const [suppressionExpired, setSuppressionExpired] = useState(false); + if (isStreaming) { + if (streamingSummary !== lastTurnSummary) { + setStreamingSummary(lastTurnSummary); + } + if (suppressionExpired) { + setSuppressionExpired(false); + } + } else if ( + streamingSummary !== undefined && + lastTurnSummary !== streamingSummary + ) { + setStreamingSummary(undefined); + if (suppressionExpired) { + setSuppressionExpired(false); + } + } + const isStaleTurnSummary = + !isStreaming && + lastTurnSummary !== undefined && + !suppressionExpired && + streamingSummary === lastTurnSummary; + useEffect(() => { + if (!isStaleTurnSummary) { + return; + } + const timeoutId = setTimeout(() => { + setSuppressionExpired(true); + }, staleTurnSummaryReleaseMs); + return () => clearTimeout(timeoutId); + }, [isStaleTurnSummary]); + const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; + const subtitle = + errorReason || streamingSubtitle || displayedTurnSummary || modelName; + const diffStatus = getChatDiffStatus(chat); + const baseConfig = getStatusConfig(chat.status); + const prConfig = + chat.status === "waiting" || chat.status === "completed" + ? getPRIconConfig(diffStatus) + : undefined; + const config = prConfig ?? baseConfig; + const StatusIcon = config.icon; + const hasLinkedDiffStatus = Boolean(diffStatus?.url); + const changedFiles = diffStatus?.changed_files ?? 0; + const additions = diffStatus?.additions ?? 0; + const deletions = diffStatus?.deletions ?? 0; + const hasLineStats = additions > 0 || deletions > 0 || changedFiles > 0; + const filesChangedLabel = `${changedFiles} ${ + changedFiles === 1 ? "file" : "files" + }`; + const workspaceId = chat.workspace_id; + const isArchivingThisChat = isArchiving && archivingChatId === chat.id; + const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id); + const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false); + + const renderMenuItems = ({ + Item, + Separator, + }: { + Item: typeof DropdownMenuItem | typeof ContextMenuItem; + Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator; + }) => ( + <> + {!chat.archived && !isChildNode && ( + + chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id) + } + > + {chat.pin_order > 0 ? ( + <> + + Unpin agent + + ) : ( + <> + + Pin agent + + )} + + )} + {chat.archived ? ( + onUnarchiveAgent(chat.id)}> + + Unarchive agent + + ) : ( + <> + {onOpenRenameDialog && ( + onOpenRenameDialog(chat)}> + + Rename chat + + )} + + onArchiveAgent(chat.id)} + > + + Archive agent + + {workspaceId && ( + onArchiveAndDeleteWorkspace(chat.id, workspaceId)} + > + + Archive & delete workspace + + )} + + )} + + ); + + return ( +
+ + +
+
+
+ +
+ {hasChildren && ( + + )} +
+ + {({ isActive }) => ( +
+
+ + {chat.title} + + {chat.has_unread && !isActiveChat && ( + (unread) + )} + {isRegeneratingThisChat && ( + + Regenerating title… + + )} +
+
+ {hasLinkedDiffStatus && hasLineStats && ( + + + +{additions} + + + −{deletions} + + + )} +
+ {subtitle} +
+
+
+ )} +
+
+ {isArchivingThisChat ? ( + + ) : ( + <> + + {chat.has_unread && !isActiveChat ? ( + + + + + + + {renderMenuItems({ + Item: DropdownMenuItem, + Separator: DropdownMenuSeparator, + })} + + + + )} +
+
+
+ + {renderMenuItems({ + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + })} + +
+ + {hasChildren && isExpanded && ( +
+ {childIDs.map((childID) => { + const childChat = chatById.get(childID); + if (!childChat) return null; + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/SortableChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/SortableChatTreeNode.tsx new file mode 100644 index 0000000000..4ce62e86d5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/SortableChatTreeNode.tsx @@ -0,0 +1,48 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { FC } from "react"; +import type { Chat } from "#/api/typesGenerated"; +import { cn } from "#/utils/cn"; +import { ChatTreeNode } from "./ChatTreeNode"; + +export const SortableChatTreeNode: FC<{ + chat: Chat; +}> = ({ chat }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: chat.id, + // Skip the derived-transform measurement after drop. + // localPinOrder already repositions items in the DOM, + // so the two-frame snap-back dance produces stale deltas + // and a visible jitter. This makes items snap directly. + animateLayoutChanges: () => false, + }); + + // Strip scaleX/scaleY that dnd-kit adds by default. + const adjustedTransform = transform + ? { ...transform, scaleX: 1, scaleY: 1 } + : null; + + const style = { + transform: CSS.Transform.toString(adjustedTransform), + transition: isDragging ? "opacity 200ms" : transition, + }; + + return ( +
+ +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/chatTree.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/chatTree.ts new file mode 100644 index 0000000000..7ebc8ce475 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/chatTree.ts @@ -0,0 +1,130 @@ +import type { Chat } from "#/api/typesGenerated"; +import { asNonEmptyString } from "../../ChatConversation/blockUtils"; + +export type ChatTree = { + readonly rootIds: readonly string[]; + readonly chatById: ReadonlyMap; + readonly childrenById: ReadonlyMap; + readonly parentById: ReadonlyMap; +}; + +export const getParentChatID = (chat: Chat): string | undefined => { + return asNonEmptyString(chat.parent_chat_id); +}; + +export const buildChatTree = (chats: readonly Chat[]): ChatTree => { + const chatById = new Map(); + const parentById = new Map(); + const childrenById = new Map(); + + // The paginated list now contains only root chats. Children + // are embedded in each root's `children` field. + for (const chat of chats) { + chatById.set(chat.id, chat); + childrenById.set(chat.id, []); + // Guard against stale cache entries: if a flat child + // entry appears in `chats` after its embedded parent has + // already set its parent link, do not overwrite the link + // with `undefined`. Without this, the defensive fallback + // below re-adds the child to its parent's list, producing + // a duplicate key in React rendering. + if (!parentById.has(chat.id)) { + parentById.set(chat.id, undefined); + } + + if (chat.children) { + for (const child of chat.children) { + chatById.set(child.id, child); + parentById.set(child.id, chat.id); + childrenById.get(chat.id)?.push(child.id); + // Children cannot have their own children (depth + // capped at 1), but initialize the map entry for + // uniform lookup. + childrenById.set(child.id, []); + } + } + } + + // Defensive fallback for cached data during rollout: if any + // chat has a parent_chat_id that points to a chat in the list + // but was not embedded, build the link. This handles stale + // cache entries from before the backend change. + for (const chat of chats) { + const parentID = getParentChatID(chat); + if ( + parentID && + parentID !== chat.id && + chatById.has(parentID) && + !parentById.get(chat.id) + ) { + parentById.set(chat.id, parentID); + childrenById.get(parentID)?.push(chat.id); + } + } + + const rootIds = chats + .map((chat) => chat.id) + .filter((chatID) => !parentById.get(chatID)); + + return { + rootIds, + chatById, + childrenById, + parentById, + }; +}; + +export const collectVisibleChatIDs = ({ + chats, + search, + tree, +}: { + readonly chats: readonly Chat[]; + readonly search: string; + readonly tree: ChatTree; +}): Set => { + if (!search) { + const allIDs = new Set(chats.map((chat) => chat.id)); + for (const chat of chats) { + for (const child of chat.children ?? []) { + allIDs.add(child.id); + } + } + return allIDs; + } + + const allChats = chats.flatMap((chat) => [chat, ...(chat.children ?? [])]); + const matchedChatIDs = allChats + .filter((chat) => chat.title.toLowerCase().includes(search)) + .map((chat) => chat.id); + if (matchedChatIDs.length === 0) { + return new Set(); + } + + const visible = new Set(); + for (const matchedChatID of matchedChatIDs) { + let parentCursor: string | undefined = matchedChatID; + const seenParents = new Set(); + while (parentCursor && !seenParents.has(parentCursor)) { + seenParents.add(parentCursor); + visible.add(parentCursor); + parentCursor = tree.parentById.get(parentCursor); + } + + const stack = [matchedChatID]; + const seenDescendants = new Set(); + while (stack.length > 0) { + const currentID = stack.pop(); + if (!currentID || seenDescendants.has(currentID)) { + continue; + } + seenDescendants.add(currentID); + visible.add(currentID); + for (const childID of tree.childrenById.get(currentID) ?? []) { + stack.push(childID); + } + } + } + + return visible; +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/modelDisplayName.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/modelDisplayName.ts new file mode 100644 index 0000000000..72d5ac6a0c --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/modelDisplayName.ts @@ -0,0 +1,55 @@ +import type { Chat, ChatModelConfig } from "#/api/typesGenerated"; +import { getNormalizedModelRef } from "../../../utils/modelOptions"; +import type { ModelSelectorOption } from "../../ChatElements"; +import { asString } from "../../ChatElements/runtimeTypeUtils"; + +export const getModelDisplayName = ( + lastModelConfigID: Chat["last_model_config_id"] | undefined, + modelConfigs: readonly ChatModelConfig[], + modelOptions: readonly ModelSelectorOption[], +) => { + const normalizedModelConfigID = asString(lastModelConfigID).trim(); + if (!normalizedModelConfigID) { + return "Default model"; + } + + const modelOption = modelOptions.find( + (option) => option.id === normalizedModelConfigID, + ); + if (modelOption?.displayName) { + return modelOption.displayName; + } + + const modelConfig = modelConfigs.find( + (config) => config.id === normalizedModelConfigID, + ); + if (!modelConfig) { + const legacyModelOption = modelOptions.find( + (option) => + `${option.provider}:${option.model}` === normalizedModelConfigID, + ); + if (legacyModelOption?.displayName) { + return legacyModelOption.displayName; + } + return "Default model"; + } + + const displayName = asString(modelConfig.display_name).trim(); + if (displayName) { + return displayName; + } + + const { provider, model } = getNormalizedModelRef(modelConfig); + if (!provider || !model) { + return "Default model"; + } + + const fallbackModelOption = modelOptions.find( + (option) => option.provider === provider && option.model === model, + ); + if (fallbackModelOption?.displayName) { + return fallbackModelOption.displayName; + } + + return model; +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts new file mode 100644 index 0000000000..68b83eb045 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/statusConfig.ts @@ -0,0 +1,60 @@ +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangleIcon, + CheckIcon, + GitMergeIcon, + GitPullRequestArrowIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + Loader2Icon, + PauseIcon, +} from "lucide-react"; +import type { Chat, ChatDiffStatus, ChatStatus } from "#/api/typesGenerated"; + +type ChatIconConfig = { + icon: LucideIcon; + className: string; +}; + +const statusConfig = { + waiting: { icon: CheckIcon, className: "text-content-secondary" }, + pending: { icon: Loader2Icon, className: "text-content-link animate-spin" }, + running: { icon: Loader2Icon, className: "text-content-link animate-spin" }, + paused: { icon: PauseIcon, className: "text-content-warning" }, + requires_action: { icon: PauseIcon, className: "text-content-warning" }, + error: { icon: AlertTriangleIcon, className: "text-content-destructive" }, + completed: { icon: CheckIcon, className: "text-content-secondary" }, +} as const; + +export const getStatusConfig = (status: ChatStatus): ChatIconConfig => { + return statusConfig[status] ?? statusConfig.completed; +}; + +export const getPRIconConfig = ( + diffStatus: ChatDiffStatus | undefined, +): ChatIconConfig | undefined => { + const state = diffStatus?.pull_request_state; + if (!state) { + return undefined; + } + if (state === "merged") { + return { icon: GitMergeIcon, className: "text-git-merged-bright" }; + } + if (state === "closed") { + return { + icon: GitPullRequestClosedIcon, + className: "text-git-deleted-bright", + }; + } + if (diffStatus?.pull_request_draft) { + return { + icon: GitPullRequestDraftIcon, + className: "text-content-secondary", + }; + } + return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" }; +}; + +export const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => { + return chat.diff_status; +}; diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx deleted file mode 100644 index f3fe3cec34..0000000000 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ /dev/null @@ -1,1733 +0,0 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - AlertTriangleIcon, - ArchiveIcon, - ArchiveRestoreIcon, - ArrowLeftIcon, - BotIcon, - BoxesIcon, - CheckIcon, - ChevronDownIcon, - ChevronRightIcon, - CoinsIcon, - EllipsisVerticalIcon, - FlaskConicalIcon, - GitMergeIcon, - GitPullRequestArrowIcon, - GitPullRequestClosedIcon, - GitPullRequestDraftIcon, - KeyIcon, - LayoutTemplateIcon, - Loader2Icon, - PanelLeftCloseIcon, - PauseIcon, - PinIcon, - PinOffIcon, - PlugIcon, - ReceiptTextIcon, - RefreshCwIcon, - ServerIcon, - Settings2Icon, - SettingsIcon, - ShieldIcon, - ShrinkIcon, - SquarePenIcon, - Trash2Icon, - UserIcon, -} from "lucide-react"; -import { - createContext, - type FC, - useContext, - useEffect, - useEffectEvent, - useRef, - useState, -} from "react"; -import { useQuery } from "react-query"; -import { Link, NavLink, useLocation, useParams } from "react-router"; -import { userChatProviderConfigs } from "#/api/queries/chats"; -import type { - Chat, - ChatDiffStatus, - ChatModelConfig, - ChatStatus, -} from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Avatar } from "#/components/Avatar/Avatar"; -import { Button } from "#/components/Button/Button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "#/components/ContextMenu/ContextMenu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { FeatureStageBadge } from "#/components/FeatureStageBadge/FeatureStageBadge"; -import { ProductLogo } from "#/components/Icons/ProductLogo"; -import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { UserDropdownContent } from "#/modules/dashboard/Navbar/UserDropdown/UserDropdownContent"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; -import { cn } from "#/utils/cn"; -import { shortRelativeTime } from "#/utils/time"; -import { getNormalizedModelRef } from "../../utils/modelOptions"; -import { getTimeGroup, TIME_GROUPS } from "../../utils/timeGroups"; -import { asNonEmptyString } from "../ChatConversation/blockUtils"; -import type { ModelSelectorOption } from "../ChatElements"; -import { asString } from "../ChatElements/runtimeTypeUtils"; -import { UsageIndicator } from "../UsageIndicator"; -import { FilterDropdown } from "./FilterDropdown"; -import { RenameChatDialog } from "./RenameChatDialog"; - -type SidebarView = - | { panel: "chats" } - | { panel: "settings"; section: string | undefined } - | { panel: "settings-admin"; section: string | undefined } - | { panel: "analytics" }; - -const ADMIN_SETTINGS_SECTIONS = new Set([ - "agents", - "templates", - "providers", - "models", - "mcp-servers", - "spend", - "insights", - "instructions", - "experiments", - "lifecycle", -]); - -/** - * Derive the current sidebar view from the URL pathname. - */ -export function sidebarViewFromPath(pathname: string): SidebarView { - if (pathname.startsWith("/agents/analytics")) { - return { panel: "analytics" }; - } - const settingsMatch = pathname.match(/^\/agents\/settings(?:\/([^/]+))?/); - if (settingsMatch) { - const section = settingsMatch[1]; - if (section === "admin") { - return { panel: "settings-admin", section: undefined }; - } - return { - panel: ADMIN_SETTINGS_SECTIONS.has(section ?? "") - ? "settings-admin" - : "settings", - section, - }; - } - return { panel: "chats" }; -} - -export function isSettingsView( - view: SidebarView, -): view is Extract { - return view.panel === "settings" || view.panel === "settings-admin"; -} - -interface AgentsSidebarProps { - chats: readonly Chat[]; - chatErrorReasons: Record; - modelOptions: readonly ModelSelectorOption[]; - modelConfigs: readonly ChatModelConfig[]; - onArchiveAgent: (chatId: string) => void; - onUnarchiveAgent: (chatId: string) => void; - onArchiveAndDeleteWorkspace: (chatId: string, workspaceId: string) => void; - onPinAgent: (chatId: string) => void; - onUnpinAgent: (chatId: string) => void; - onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; - onRenameTitle?: (chatId: string, title: string) => Promise; - onProposeTitle?: (chatId: string) => Promise; - onBeforeNewAgent?: () => void; - isCreating: boolean; - isArchiving?: boolean; - archivingChatId?: string | null; - regeneratingTitleChatIds: readonly string[]; - isLoading?: boolean; - loadError?: unknown; - onRetryLoad?: () => void; - hasNextPage?: boolean; - onLoadMore?: () => void; - isFetchingNextPage?: boolean; - archivedFilter: "active" | "archived"; - onArchivedFilterChange?: (filter: "active" | "archived") => void; - onCollapse?: () => void; - isPersonalModelOverridesEnabled?: boolean; - isAdmin?: boolean; -} - -const statusConfig = { - waiting: { icon: CheckIcon, className: "text-content-secondary" }, - pending: { icon: Loader2Icon, className: "text-content-link animate-spin" }, - running: { icon: Loader2Icon, className: "text-content-link animate-spin" }, - paused: { icon: PauseIcon, className: "text-content-warning" }, - requires_action: { icon: PauseIcon, className: "text-content-warning" }, - error: { icon: AlertTriangleIcon, className: "text-content-destructive" }, - completed: { icon: CheckIcon, className: "text-content-secondary" }, -} as const; - -type ChatTree = { - readonly rootIds: readonly string[]; - readonly chatById: ReadonlyMap; - readonly childrenById: ReadonlyMap; - readonly parentById: ReadonlyMap; -}; - -const getStatusConfig = (status: ChatStatus) => { - return statusConfig[status] ?? statusConfig.completed; -}; - -/** - * Returns the icon and className to use for a PR state, or undefined - * if there is no PR linked. Only overrides the icon when the chat - * is not actively executing (pending/running/paused/error). - */ -const getPRIconConfig = ( - diffStatus: ChatDiffStatus | undefined, -): { icon: typeof CheckIcon; className: string } | undefined => { - const state = diffStatus?.pull_request_state; - if (!state) { - return undefined; - } - if (state === "merged") { - return { icon: GitMergeIcon, className: "text-git-merged-bright" }; - } - if (state === "closed") { - return { - icon: GitPullRequestClosedIcon, - className: "text-git-deleted-bright", - }; - } - // state === "open" - if (diffStatus?.pull_request_draft) { - return { - icon: GitPullRequestDraftIcon, - className: "text-content-secondary", - }; - } - return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" }; -}; - -const getModelDisplayName = ( - lastModelConfigID: Chat["last_model_config_id"] | undefined, - modelConfigs: readonly ChatModelConfig[], - modelOptions: readonly ModelSelectorOption[], -) => { - const normalizedModelConfigID = asString(lastModelConfigID).trim(); - if (!normalizedModelConfigID) { - return "Default model"; - } - - const modelOption = modelOptions.find( - (option) => option.id === normalizedModelConfigID, - ); - if (modelOption?.displayName) { - return modelOption.displayName; - } - - const modelConfig = modelConfigs.find( - (config) => config.id === normalizedModelConfigID, - ); - if (!modelConfig) { - const legacyModelOption = modelOptions.find( - (option) => - `${option.provider}:${option.model}` === normalizedModelConfigID, - ); - if (legacyModelOption?.displayName) { - return legacyModelOption.displayName; - } - return "Default model"; - } - - const displayName = asString(modelConfig.display_name).trim(); - if (displayName) { - return displayName; - } - - const { provider, model } = getNormalizedModelRef(modelConfig); - if (!provider || !model) { - return "Default model"; - } - - const fallbackModelOption = modelOptions.find( - (option) => option.provider === provider && option.model === model, - ); - if (fallbackModelOption?.displayName) { - return fallbackModelOption.displayName; - } - - return model; -}; - -const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => { - return chat.diff_status; -}; - -const getParentChatID = (chat: Chat): string | undefined => { - return asNonEmptyString(chat.parent_chat_id); -}; - -const buildChatTree = (chats: readonly Chat[]): ChatTree => { - const chatById = new Map(); - const parentById = new Map(); - const childrenById = new Map(); - - // The paginated list now contains only root chats. Children - // are embedded in each root's `children` field. - for (const chat of chats) { - chatById.set(chat.id, chat); - childrenById.set(chat.id, []); - // Guard against stale cache entries: if a flat child - // entry appears in `chats` after its embedded parent has - // already set its parent link, do not overwrite the link - // with `undefined`. Without this, the defensive fallback - // below re-adds the child to its parent's list, producing - // a duplicate key in React rendering. - if (!parentById.has(chat.id)) { - parentById.set(chat.id, undefined); - } - - if (chat.children) { - for (const child of chat.children) { - chatById.set(child.id, child); - parentById.set(child.id, chat.id); - childrenById.get(chat.id)?.push(child.id); - // Children cannot have their own children (depth - // capped at 1), but initialize the map entry for - // uniform lookup. - childrenById.set(child.id, []); - } - } - } - - // Defensive fallback for cached data during rollout: if any - // chat has a parent_chat_id that points to a chat in the list - // but was not embedded, build the link. This handles stale - // cache entries from before the backend change. - for (const chat of chats) { - const parentID = getParentChatID(chat); - if ( - parentID && - parentID !== chat.id && - chatById.has(parentID) && - !parentById.get(chat.id) - ) { - parentById.set(chat.id, parentID); - childrenById.get(parentID)?.push(chat.id); - } - } - - const rootIds = chats - .map((chat) => chat.id) - .filter((chatID) => !parentById.get(chatID)); - - return { - rootIds, - chatById, - childrenById, - parentById, - }; -}; - -const collectVisibleChatIDs = ({ - chats, - search, - tree, -}: { - readonly chats: readonly Chat[]; - readonly search: string; - readonly tree: ChatTree; -}): Set => { - if (!search) { - const allIDs = new Set(chats.map((chat) => chat.id)); - for (const chat of chats) { - for (const child of chat.children ?? []) { - allIDs.add(child.id); - } - } - return allIDs; - } - - const allChats = chats.flatMap((chat) => [chat, ...(chat.children ?? [])]); - const matchedChatIDs = allChats - .filter((chat) => chat.title.toLowerCase().includes(search)) - .map((chat) => chat.id); - if (matchedChatIDs.length === 0) { - return new Set(); - } - - const visible = new Set(); - for (const matchedChatID of matchedChatIDs) { - let parentCursor: string | undefined = matchedChatID; - const seenParents = new Set(); - while (parentCursor && !seenParents.has(parentCursor)) { - seenParents.add(parentCursor); - visible.add(parentCursor); - parentCursor = tree.parentById.get(parentCursor); - } - - const stack = [matchedChatID]; - const seenDescendants = new Set(); - while (stack.length > 0) { - const currentID = stack.pop(); - if (!currentID || seenDescendants.has(currentID)) { - continue; - } - seenDescendants.add(currentID); - visible.add(currentID); - for (const childID of tree.childrenById.get(currentID) ?? []) { - stack.push(childID); - } - } - } - - return visible; -}; - -interface ChatTreeContextValue { - readonly chatTree: ChatTree; - readonly chatById: ReadonlyMap; - readonly visibleChatIDs: ReadonlySet; - readonly normalizedSearch: string; - readonly expandedById: Record; - readonly modelOptions: readonly ModelSelectorOption[]; - readonly modelConfigs: readonly ChatModelConfig[]; - readonly chatErrorReasons: Record; - readonly activeChatId: string | undefined; - readonly isArchiving: boolean; - readonly archivingChatId: string | null; - readonly regeneratingTitleChatIds: readonly string[]; - readonly toggleExpanded: (chatID: string) => void; - readonly onArchiveAgent: (chatId: string) => void; - readonly onUnarchiveAgent: (chatId: string) => void; - readonly onArchiveAndDeleteWorkspace: ( - chatId: string, - workspaceId: string, - ) => void; - readonly onPinAgent: (chatId: string) => void; - readonly onUnpinAgent: (chatId: string) => void; - readonly onOpenRenameDialog?: (chat: Chat) => void; -} - -const ChatTreeContext = createContext(null); - -function useChatTree(): ChatTreeContextValue { - const ctx = useContext(ChatTreeContext); - if (!ctx) { - throw new Error("useChatTree must be used within ChatTreeContext.Provider"); - } - return ctx; -} - -interface ChatTreeNodeProps { - readonly chat: Chat; - readonly isChildNode: boolean; -} - -const ChatTreeNode: FC = ({ chat, isChildNode }) => { - const location = useLocation(); - const { - chatTree, - chatById, - visibleChatIDs, - normalizedSearch, - expandedById, - modelOptions, - modelConfigs, - chatErrorReasons, - activeChatId, - isArchiving, - archivingChatId, - regeneratingTitleChatIds, - toggleExpanded, - onArchiveAgent, - onUnarchiveAgent, - onArchiveAndDeleteWorkspace, - onPinAgent, - onUnpinAgent, - onOpenRenameDialog, - } = useChatTree(); - const chatID = chat.id; - const isActiveChat = activeChatId === chatID; - const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) => - visibleChatIDs.has(childID), - ); - const hasChildren = childIDs.length > 0; - const isDelegated = Boolean(getParentChatID(chat)); - const isDelegatedExecuting = - isDelegated && (chat.status === "pending" || chat.status === "running"); - const modelName = getModelDisplayName( - chat.last_model_config_id, - modelConfigs, - modelOptions, - ); - const errorReason = - chat.status === "error" - ? chatErrorReasons[chat.id] || chat.last_error?.message || undefined - : undefined; - const lastTurnSummary = asNonEmptyString(chat.last_turn_summary); - // While a turn is in flight the cached last_turn_summary still holds - // the previous turn's text. Surface a live "{model} streaming…" label - // instead so the sidebar does not look stuck on the old status. - const isStreaming = chat.status === "running" || chat.status === "pending"; - const streamingSubtitle = isStreaming ? `${modelName} streaming…` : undefined; - // Server-side, status flips to "waiting" synchronously when streaming - // ends, but the new last_turn_summary is written by an async finalizer - // (it calls the model to generate a label). For a beat after the stream - // stops, the chat row still carries the previous turn's summary text. - // Suppress that briefly-stale text: remember the cached summary observed - // while streaming, then hide an exact match on the post-stream renders - // until the new summary lands. A timeout-based release covers the rare - // case where the regenerated summary equals the previous one byte-for- - // byte (so the equality-based release would never fire). State is - // captured during render using the React "adjust state during render" - // pattern so the first post-stream paint already suppresses the stale - // string instead of flashing it for a frame. - const staleTurnSummaryReleaseMs = 10_000; - const [streamingSummary, setStreamingSummary] = useState( - isStreaming ? lastTurnSummary : undefined, - ); - const [suppressionExpired, setSuppressionExpired] = useState(false); - if (isStreaming) { - if (streamingSummary !== lastTurnSummary) { - setStreamingSummary(lastTurnSummary); - } - if (suppressionExpired) { - setSuppressionExpired(false); - } - } else if ( - streamingSummary !== undefined && - lastTurnSummary !== streamingSummary - ) { - setStreamingSummary(undefined); - if (suppressionExpired) { - setSuppressionExpired(false); - } - } - const isStaleTurnSummary = - !isStreaming && - lastTurnSummary !== undefined && - !suppressionExpired && - streamingSummary === lastTurnSummary; - useEffect(() => { - if (!isStaleTurnSummary) { - return; - } - const timeoutId = window.setTimeout(() => { - setSuppressionExpired(true); - }, staleTurnSummaryReleaseMs); - return () => window.clearTimeout(timeoutId); - }, [isStaleTurnSummary]); - const displayedTurnSummary = isStaleTurnSummary ? undefined : lastTurnSummary; - const subtitle = - errorReason || streamingSubtitle || displayedTurnSummary || modelName; - const diffStatus = getChatDiffStatus(chat); - const baseConfig = getStatusConfig(chat.status); - const prConfig = - chat.status === "waiting" || chat.status === "completed" - ? getPRIconConfig(diffStatus) - : undefined; - const config = prConfig ?? baseConfig; - const StatusIcon = config.icon; - const hasLinkedDiffStatus = Boolean(diffStatus?.url); - const changedFiles = diffStatus?.changed_files ?? 0; - const additions = diffStatus?.additions ?? 0; - const deletions = diffStatus?.deletions ?? 0; - const hasLineStats = additions > 0 || deletions > 0 || changedFiles > 0; - const filesChangedLabel = `${changedFiles} ${ - changedFiles === 1 ? "file" : "files" - }`; - const workspaceId = chat.workspace_id; - const isArchivingThisChat = isArchiving && archivingChatId === chat.id; - const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id); - const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false); - - const renderMenuItems = ({ - Item, - Separator, - }: { - Item: typeof DropdownMenuItem | typeof ContextMenuItem; - Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator; - }) => ( - <> - {!chat.archived && !isChildNode && ( - - chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id) - } - > - {chat.pin_order > 0 ? ( - <> - - Unpin agent - - ) : ( - <> - - Pin agent - - )} - - )} - {chat.archived ? ( - onUnarchiveAgent(chat.id)}> - - Unarchive agent - - ) : ( - <> - {onOpenRenameDialog && ( - onOpenRenameDialog(chat)}> - - Rename chat - - )} - - onArchiveAgent(chat.id)} - > - - Archive agent - - {workspaceId && ( - onArchiveAndDeleteWorkspace(chat.id, workspaceId)} - > - - Archive & delete workspace - - )} - - )} - - ); - - return ( -
- - -
-
-
- -
- {hasChildren && ( - - )} -
- - {({ isActive }) => ( - <> -
-
- - {chat.title} - - {chat.has_unread && !isActiveChat && ( - (unread) - )} - {isRegeneratingThisChat && ( - - Regenerating title… - - )} -
-
- {hasLinkedDiffStatus && hasLineStats && ( - - - +{additions} - - - −{deletions} - - - )} -
- {subtitle} -
-
-
- - )} -
-
- {isArchivingThisChat ? ( - - ) : ( - <> - - {chat.has_unread && !isActiveChat ? ( - - - - - - - {renderMenuItems({ - Item: DropdownMenuItem, - Separator: DropdownMenuSeparator, - })} - - - - )} -
-
-
- - {renderMenuItems({ - Item: ContextMenuItem, - Separator: ContextMenuSeparator, - })} - -
- - {hasChildren && isExpanded && ( -
- {childIDs.map((childID) => { - const childChat = chatById.get(childID); - if (!childChat) return null; - return ( - - ); - })} -
- )} -
- ); -}; - -const SortableChatTreeNode: FC<{ - chat: Chat; -}> = ({ chat }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: chat.id, - // Skip the derived-transform measurement after drop. - // localPinOrder already repositions items in the DOM, - // so the two-frame snap-back dance produces stale deltas - // and a visible jitter. This makes items snap directly. - animateLayoutChanges: () => false, - }); - - // Strip scaleX/scaleY that dnd-kit adds by default. - const adjustedTransform = transform - ? { ...transform, scaleX: 1, scaleY: 1 } - : null; - - const style = { - transform: CSS.Transform.toString(adjustedTransform), - transition: isDragging ? "opacity 200ms" : transition, - }; - - return ( -
- -
- ); -}; - -const PINNED_SECTION_KEY = "Pinned"; - -const getSectionToggleTestId = (sectionKey: string) => - `agents-section-toggle-${sectionKey.replaceAll(" ", "-")}`; - -interface ChatSectionHeaderProps { - readonly label: string; - readonly count: number; - readonly expanded: boolean; - readonly onToggle: () => void; - readonly testId: string; -} - -const ChatSectionHeader: FC = ({ - label, - count, - expanded, - onToggle, - testId, -}) => { - const actionLabel = expanded ? "Collapse" : "Expand"; - return ( -
- -
- ); -}; - -export const AgentsSidebar: FC = (props) => { - const { - chats, - chatErrorReasons, - modelOptions, - modelConfigs, - onArchiveAgent, - onUnarchiveAgent, - onArchiveAndDeleteWorkspace, - onPinAgent, - onUnpinAgent, - onReorderPinnedAgent, - onRenameTitle, - onProposeTitle, - onBeforeNewAgent, - isCreating, - isArchiving = false, - archivingChatId = null, - regeneratingTitleChatIds, - isLoading = false, - loadError, - onRetryLoad, - hasNextPage, - onLoadMore, - isFetchingNextPage, - archivedFilter, - onArchivedFilterChange, - onCollapse, - isPersonalModelOverridesEnabled = false, - isAdmin = false, - } = props; - const { agentId, chatId } = useParams<{ - agentId?: string; - chatId?: string; - }>(); - const activeChatId = agentId ?? chatId; - const { user, signOut } = useAuthenticated(); - const { appearance, buildInfo } = useDashboard(); - const location = useLocation(); - const sidebarView = sidebarViewFromPath(location.pathname); - const isSettingsPanel = isSettingsView(sidebarView); - const isFallbackToUserPanel = - sidebarView.panel === "settings-admin" && !isAdmin; - const settingsPanel = - sidebarView.panel === "settings-admin" && isAdmin - ? "settings-admin" - : "settings"; - const settingsSection = - isSettingsPanel && !isFallbackToUserPanel ? sidebarView.section : undefined; - const providerConfigsQuery = useQuery({ - ...userChatProviderConfigs(), - enabled: isSettingsPanel && !isAdmin, - }); - const isApiKeysSection = isSettingsPanel && settingsSection === "api-keys"; - const showApiKeysItem = - isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length); - const normalizedSearch = ""; - const [expandedById, setExpandedById] = useState>({}); - const [collapsedSections, setCollapsedSections] = useState< - Record - >({}); - const [chatPendingRename, setChatPendingRename] = useState(null); - - const chatTree = buildChatTree(chats); - const chatById = chatTree.chatById; - const visibleChatIDs = collectVisibleChatIDs({ - chats, - search: normalizedSearch, - tree: chatTree, - }); - const visibleRootIDs = chatTree.rootIds.filter((chatID) => - visibleChatIDs.has(chatID), - ); - - const pinnedChats = visibleRootIDs - .map((id) => chatById.get(id)) - .filter((chat): chat is Chat => (chat?.pin_order ?? 0) > 0) - .sort((a, b) => a.pin_order - b.pin_order); - - // Local override for pinned order during drag. Applied - // synchronously so there's no flash between the dnd-kit - // transform clearing and the server data arriving. - const [localPinOrder, setLocalPinOrder] = useState(null); - - // Clear the local override when fresh data arrives from - // the server (the mutation's onSettled invalidates queries). - const chatsRef = useRef(chats); - useEffect(() => { - if (chats !== chatsRef.current) { - chatsRef.current = chats; - setLocalPinOrder(null); - } - }, [chats]); - - const sortedPinnedChats = localPinOrder - ? localPinOrder - .map((id) => pinnedChats.find((c) => c.id === id)) - .filter((c) => c !== undefined) - : pinnedChats; - - const pinnedChatIds = sortedPinnedChats.map((chat) => chat.id); - - const lastDragEndedAtRef = useRef(0); - - const pinnedContainerRef = useRef(null); - useEffect(() => { - const handler = (e: MouseEvent) => { - const container = pinnedContainerRef.current; - const target = e.target; - if ( - container && - target instanceof Node && - container.contains(target) && - performance.now() - lastDragEndedAtRef.current < 300 - ) { - e.preventDefault(); - } - }; - document.addEventListener("click", handler, true); - return () => document.removeEventListener("click", handler, true); - }, []); - - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { distance: 5 }, - }), - useSensor(TouchSensor, { - activationConstraint: { delay: 200, tolerance: 5 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - lastDragEndedAtRef.current = performance.now(); - if (!over || active.id === over.id) return; - const activeId = String(active.id); - const overId = String(over.id); - const oldIndex = pinnedChatIds.indexOf(activeId); - const newIndex = pinnedChatIds.indexOf(overId); - if (oldIndex === -1 || newIndex === -1) return; - - const reordered = arrayMove(pinnedChatIds, oldIndex, newIndex); - setLocalPinOrder(reordered); - onReorderPinnedAgent?.(activeId, newIndex + 1); - }; - - // Auto-expand ancestors of the active chat so it's always visible. - // Only runs when activeChatId changes, not on every parentById - // recalculation, so user-initiated collapse is preserved. - const parentByIdRef = useRef(chatTree.parentById); - useEffect(() => { - parentByIdRef.current = chatTree.parentById; - }); - useEffect(() => { - if (!activeChatId) { - return; - } - const parentById = parentByIdRef.current; - const toExpand: string[] = []; - let cursor = parentById.get(activeChatId); - const seen = new Set(); - while (cursor && !seen.has(cursor)) { - seen.add(cursor); - toExpand.push(cursor); - cursor = parentById.get(cursor); - } - if (toExpand.length > 0) { - setExpandedById((prev) => { - if (toExpand.every((id) => prev[id])) { - return prev; - } - const next = { ...prev }; - for (const id of toExpand) { - next[id] = true; - } - return next; - }); - } - }, [activeChatId]); - const toggleExpanded = (chatID: string) => { - setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] })); - }; - const toggleSection = (sectionKey: string) => { - setCollapsedSections((prev) => ({ - ...prev, - [sectionKey]: !prev[sectionKey], - })); - }; - - const chatTreeCtx: ChatTreeContextValue = { - chatTree, - chatById, - visibleChatIDs, - normalizedSearch, - expandedById, - modelOptions, - modelConfigs, - chatErrorReasons, - activeChatId, - isArchiving, - archivingChatId, - regeneratingTitleChatIds, - toggleExpanded, - onArchiveAgent, - onUnarchiveAgent, - onArchiveAndDeleteWorkspace, - onPinAgent, - onUnpinAgent, - onOpenRenameDialog: onRenameTitle ? setChatPendingRename : undefined, - }; - - const subNavTitle = - settingsPanel === "settings-admin" ? "Manage Agents" : "Settings"; - return ( -
- {/* ── Panel 1: Chats ── */} -
-
-
-
- - - - -
-
- - {onCollapse && ( - - )} -
-
- -
-
- -
- {loadError ? ( -
- - {onRetryLoad && ( - - )} -
- ) : isLoading ? ( - <> - -
- {Array.from({ length: 6 }, (_, i) => ( -
- -
- - -
-
- ))} -
- - ) : ( - - {visibleRootIDs.length === 0 ? ( -
-

- {normalizedSearch - ? "No matching agents" - : archivedFilter === "archived" - ? "No archived agents" - : "No agents yet"} -

- -
- ) : ( -
- {visibleRootIDs.length > 0 && ( -
-
- -
- {/* ── Pinned section ── */} - {pinnedChats.length > 0 && ( -
- - toggleSection(PINNED_SECTION_KEY) - } - testId={getSectionToggleTestId( - PINNED_SECTION_KEY, - )} - /> - {!collapsedSections[PINNED_SECTION_KEY] && ( - - -
- {sortedPinnedChats.map((chat) => ( - - ))} -
-
-
- )} -
- )} - {/* ── Time-grouped sections ── */} - {TIME_GROUPS.map((group) => { - const groupChats = visibleRootIDs - .map((id) => chatById.get(id)) - .filter( - (chat): chat is Chat => - chat !== undefined && - getTimeGroup(chat.updated_at) === group && - chat.pin_order === 0, - ); - if (groupChats.length === 0) return null; - const isGroupExpanded = !collapsedSections[group]; - return ( -
- toggleSection(group)} - testId={getSectionToggleTestId(group)} - /> - {isGroupExpanded && ( -
- {groupChats.map((chat) => ( - - ))} -
- )} -
- ); - })} -
- )} -
- )} - {(hasNextPage || isFetchingNextPage) && ( - - )} -
- )} -
-
-
-
-
- - - - - - link.location !== "navbar", - ) ?? [] - } - onSignOut={signOut} - /> - - - -
-
-
- {/* ── Panel 2: Sub-navigation (Settings) ── */} -
- {/* Back header */} -
-
- - {subNavTitle} - - -
- {onCollapse && ( - - )} -
-
- {/* Sub-navigation items */} - {settingsPanel === "settings" ? ( - - ) : ( - - )} -
- {onRenameTitle && ( - { - if (!open) setChatPendingRename(null); - }} - /> - )} -
- ); -}; - -type SettingsNavItemProps = { - icon: FC<{ className?: string }>; - label: string; - active: boolean; - adminOnly?: boolean; - disabled?: boolean; - trailingIcon?: FC<{ className?: string }>; -} & ( - | { to: string; replace?: boolean; state?: unknown; onClick?: () => void } - | { to?: never; replace?: never; state?: never; onClick: () => void } -); - -const navItemClassName = (active: boolean, disabled: boolean | undefined) => - cn( - "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline", - active - ? "bg-surface-quaternary/25 text-content-primary font-medium" - : "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary", - disabled && "opacity-50 pointer-events-none", - ); - -const NavItemContent: FC<{ - icon: FC<{ className?: string }>; - label: string; - adminOnly?: boolean; - trailingIcon?: FC<{ className?: string }>; -}> = ({ icon: Icon, label, adminOnly, trailingIcon: TrailingIcon }) => ( - <> - - {label} - {(adminOnly || TrailingIcon) && ( - - {adminOnly && ( - - - - - - - Admin only - - )} - {TrailingIcon && } - - )} - -); - -const SettingsNavItem: FC = ({ - icon, - label, - active, - adminOnly, - disabled, - trailingIcon, - ...rest -}) => { - if (rest.to != null) { - return ( - - - - ); - } - - return ( - - ); -}; - -const LoadMoreSentinel: FC<{ - onLoadMore?: () => void; - isFetchingNextPage?: boolean; -}> = ({ onLoadMore, isFetchingNextPage }) => { - const sentinelRef = useRef(null); - const onLoadMoreEvent = useEffectEvent(() => { - onLoadMore?.(); - }); - - useEffect(() => { - // Don't observe while a fetch is in progress. When the - // fetch completes this effect re-runs, creating a fresh - // observer whose initial entry detects the sentinel if - // it's still visible, fixing the case where loaded items - // don't push the sentinel out of view and the previous - // observer never re-fires. - if (isFetchingNextPage) return; - - const el = sentinelRef.current; - if (!el) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - onLoadMoreEvent(); - } - }, - { threshold: 0 }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [isFetchingNextPage]); - - return ( -
- {isFetchingNextPage && ( - - )} -
- ); -};