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.
This commit is contained in:
Jaayden Halko
2026-05-20 14:06:54 +01:00
committed by GitHub
parent 00e8b40cb0
commit 1ba54e2ca6
34 changed files with 1945 additions and 1813 deletions
@@ -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";
@@ -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();
@@ -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";
+6 -6
View File
@@ -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<AgentsPageViewProps> = ({
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary sm:flex-row"
>
<title>{pageTitle("Agents")}</title>
<ResizableAgentsSidebarFrame
<ResizableChatsSidebarFrame
className={cn(
"sm:h-full sm:min-h-0 sm:border-b-0",
agentId
@@ -173,7 +173,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isSidebarCollapsed && "sm:hidden",
)}
>
<AgentsSidebar
<ChatsSidebar
chats={chatList}
chatErrorReasons={sidebarChatErrorReasons}
modelOptions={catalogModelOptions}
@@ -203,7 +203,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAgentsAdmin}
/>
</ResizableAgentsSidebarFrame>
</ResizableChatsSidebarFrame>
<div
data-testid="agents-main-panel"
className={cn(
@@ -2,7 +2,7 @@ import { type CSSProperties, type FC, useState } from "react";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { loadPersistedLeftSidebarWidth } from "./Sidebar/sidebarWidth";
import { loadPersistedLeftSidebarWidth } from "./ChatsSidebar/sidebarWidth";
/** localStorage keys shared with the agents panel components. */
const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open";
@@ -747,7 +747,7 @@ describe("buildEditDiff", () => {
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;" },
@@ -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> = {}): 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<typeof AgentsSidebar> = {
title: "pages/AgentsPage/AgentsSidebar",
component: AgentsSidebar,
const meta: Meta<typeof ChatsSidebar> = {
title: "pages/AgentsPage/ChatsSidebar",
component: ChatsSidebar,
decorators: [withAuthProvider, withDashboardProvider],
args: {
chatErrorReasons: {},
@@ -125,7 +124,7 @@ const meta: Meta<typeof AgentsSidebar> = {
};
export default meta;
type Story = StoryObj<typeof AgentsSidebar>;
type Story = StoryObj<typeof ChatsSidebar>;
export const ChatWithTurnSummary: Story = {
args: {
@@ -226,7 +225,7 @@ export const StaleTurnSummaryAfterStreamingIsSuppressed: Story = {
>
advance
</button>
<AgentsSidebar {...args} chats={chats} />
<ChatsSidebar {...args} chats={chats} />
</div>
);
},
@@ -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> = {}): 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<PropsWithChildren> = ({ children }) => {
);
};
const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {
const defaultProps: React.ComponentProps<typeof ChatsSidebar> = {
chats: [buildChat({ id: "chat-1", title: "Chat One" })],
chatErrorReasons: {},
modelOptions: [],
@@ -124,14 +123,14 @@ const defaultProps: React.ComponentProps<typeof AgentsSidebar> = {
// ---- 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(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
onArchivedFilterChange={onArchivedFilterChange}
/>
@@ -150,7 +149,7 @@ describe("AgentsSidebar archived filter", () => {
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[]}
archivedFilter="archived"
@@ -165,7 +164,7 @@ describe("AgentsSidebar archived filter", () => {
});
});
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(
<Wrapper>
<AgentsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore} />
<ChatsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore} />
</Wrapper>,
);
@@ -195,7 +194,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore = vi.fn();
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -215,7 +214,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore1 = vi.fn();
const { rerender } = render(
<Wrapper>
<AgentsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore1} />
<ChatsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore1} />
</Wrapper>,
);
@@ -226,7 +225,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore2 = vi.fn();
rerender(
<Wrapper>
<AgentsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore2} />
<ChatsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore2} />
</Wrapper>,
);
@@ -245,7 +244,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore = vi.fn();
const { rerender } = render(
<Wrapper>
<AgentsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore} />
<ChatsSidebar {...defaultProps} hasNextPage onLoadMore={onLoadMore} />
</Wrapper>,
);
@@ -260,7 +259,7 @@ describe("AgentsSidebar load-more behavior", () => {
for (let i = 0; i < 10; i++) {
rerender(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={() => onLoadMore()}
@@ -278,7 +277,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore = vi.fn();
const { rerender } = render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -296,7 +295,7 @@ describe("AgentsSidebar load-more behavior", () => {
// Fetch completes.
rerender(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -316,7 +315,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore = vi.fn();
const { rerender } = render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -331,7 +330,7 @@ describe("AgentsSidebar load-more behavior", () => {
// Start fetching — observer is torn down.
rerender(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -344,7 +343,7 @@ describe("AgentsSidebar load-more behavior", () => {
// an initial entry that detects the still-visible sentinel.
rerender(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage
onLoadMore={onLoadMore}
@@ -360,7 +359,7 @@ describe("AgentsSidebar load-more behavior", () => {
const onLoadMore = vi.fn();
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
hasNextPage={false}
onLoadMore={onLoadMore}
@@ -374,7 +373,7 @@ describe("AgentsSidebar load-more behavior", () => {
});
});
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(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -441,7 +440,7 @@ describe("AgentsSidebar model display names", () => {
it("falls back to legacy provider/model matching when no config ID match exists", () => {
const { getByText } = render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -468,7 +467,7 @@ describe("AgentsSidebar model display names", () => {
it("shows Default model when last_model_config_id is a nil UUID", () => {
const { getByText } = render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -497,7 +496,7 @@ describe("AgentsSidebar model display names", () => {
it("shows model name when last_model_config_id matches a config", () => {
const { getByText, queryByText } = render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -525,7 +524,7 @@ describe("AgentsSidebar model display names", () => {
});
});
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(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -561,7 +560,7 @@ describe("AgentsSidebar subtitles", () => {
it("shows the error when both error and last turn summary exist", () => {
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -589,7 +588,7 @@ describe("AgentsSidebar subtitles", () => {
it("falls back to the model name when no last turn summary exists", () => {
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -608,7 +607,7 @@ describe("AgentsSidebar subtitles", () => {
it("falls back to the model name when the last turn summary is blank", () => {
render(
<Wrapper>
<AgentsSidebar
<ChatsSidebar
{...defaultProps}
chats={[
buildChat({
@@ -0,0 +1,156 @@
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { useLocation, useParams } from "react-router";
import { userChatProviderConfigs } from "#/api/queries/chats";
import type { Chat, ChatModelConfig } from "#/api/typesGenerated";
import type { ModelSelectorOption } from "../ChatElements";
import { ChatsPanel } from "./chats/ChatsPanel";
import { RenameChatDialog } from "./dialogs";
import { SettingsPanel } from "./settings/SettingsPanel";
import { isSettingsView, sidebarViewFromPath } from "./sidebarView";
export { isSettingsView, sidebarViewFromPath } from "./sidebarView";
interface ChatsSidebarProps {
chats: readonly Chat[];
chatErrorReasons: Record<string, string>;
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<void>;
onProposeTitle?: (chatId: string) => Promise<string>;
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<ChatsSidebarProps> = (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<Chat | null>(null);
return (
<div className="relative flex h-full w-full min-h-0 border-0 border-r border-solid overflow-hidden">
<ChatsPanel
chats={chats}
chatErrorReasons={chatErrorReasons}
modelOptions={modelOptions}
modelConfigs={modelConfigs}
onArchiveAgent={onArchiveAgent}
onUnarchiveAgent={onUnarchiveAgent}
onArchiveAndDeleteWorkspace={onArchiveAndDeleteWorkspace}
onPinAgent={onPinAgent}
onUnpinAgent={onUnpinAgent}
onReorderPinnedAgent={onReorderPinnedAgent}
onBeforeNewAgent={onBeforeNewAgent}
onOpenRenameDialog={onRenameTitle ? setChatPendingRename : undefined}
isCreating={isCreating}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
regeneratingTitleChatIds={regeneratingTitleChatIds}
isLoading={isLoading}
loadError={loadError}
onRetryLoad={onRetryLoad}
hasNextPage={hasNextPage}
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
onCollapse={onCollapse}
activeChatId={activeChatId}
isSettingsPanel={isSettingsPanel}
isChatsActive={!activeChatId && sidebarView.panel === "chats"}
location={location}
/>
<SettingsPanel
isSettingsPanel={isSettingsPanel}
settingsPanel={settingsPanel}
settingsSection={settingsSection}
showApiKeysItem={showApiKeysItem}
isPersonalModelOverridesEnabled={isPersonalModelOverridesEnabled}
isAdmin={isAdmin}
location={location}
onCollapse={onCollapse}
/>
{onRenameTitle && (
<RenameChatDialog
chat={chatPendingRename}
onRename={onRenameTitle}
onPropose={onProposeTitle}
onOpenChange={(open: boolean) => {
if (!open) setChatPendingRename(null);
}}
/>
)}
</div>
);
};
@@ -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);
@@ -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<ChatSectionHeaderProps> = ({
label,
count,
expanded,
onToggle,
testId,
}) => {
const actionLabel = expanded ? "Collapse" : "Expand";
return (
<div className="group/header mb-1 ml-2.5 mr-2 flex h-7 items-center text-xs font-medium text-content-secondary">
<button
type="button"
className="flex h-7 min-w-0 flex-1 cursor-pointer appearance-none items-center rounded-md border-0 bg-transparent p-0 text-left font-sans text-xs font-medium text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link [@media(hover:hover)]:group-hover/header:text-content-primary"
aria-expanded={expanded}
aria-label={`${actionLabel} ${label} section`}
data-testid={testId}
onClick={onToggle}
>
<span className="min-w-0 flex-1 truncate">
{label} ({count})
</span>
<span className="flex h-6 w-7 shrink-0 items-center justify-end">
<ChevronDownIcon
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 text-current transition-transform",
expanded && "rotate-180",
)}
/>
</span>
</button>
</div>
);
};
@@ -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<string, string>;
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<ChatsPanelProps> = ({
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<Record<string, boolean>>({});
const [collapsedSections, setCollapsedSections] = useState<
Record<string, boolean>
>({});
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<string[] | null>(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<HTMLDivElement | null>(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"]>(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<string>();
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 (
<div
className={cn(
"absolute inset-0 flex flex-col sm:transition-transform sm:duration-200 sm:ease-in-out",
isSettingsPanel && "-translate-x-full",
)}
aria-hidden={isSettingsPanel}
inert={isSettingsPanel ? true : undefined}
>
<div className="hidden border-b border-border-default px-2 pb-3 pt-1.5 sm:block">
<div className="mb-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<NavLink to="/workspaces" className="inline-flex">
<ProductLogo className="size-6" />
</NavLink>
<FeatureStageBadge contentType="beta" size="xs" />
</div>
<div className="flex items-center gap-0.5 -mr-1.5">
<Button
asChild
variant="subtle"
size="icon"
aria-label="Settings"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
isSettingsPanel && "text-content-primary",
)}
>
<Link
to="/agents/settings"
state={{ from: location.pathname + location.search }}
>
<SettingsIcon />
</Link>
</Button>
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
</div>
<SettingsNavItem
icon={SquarePenIcon}
label="New Agent"
active={isChatsActive}
to={`/agents${location.search}`}
onClick={onBeforeNewAgent}
disabled={isCreating}
/>
</div>
<div className="relative min-h-0 flex-1">
<ScrollArea
className="h-full [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
viewportClassName={cn(
"[mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
"[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
"sm:[mask-image:none] sm:[-webkit-mask-image:none]",
)}
>
<div className="flex flex-col gap-2 px-2 py-3">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</>
) : (
<ChatTreeContext value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
<p className="m-0">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</p>
<button
type="button"
className="mt-2 cursor-pointer border-none bg-transparent p-0 text-xs text-content-secondary hover:text-content-primary hover:underline"
onClick={() =>
onArchivedFilterChange?.(
archivedFilter === "archived" ? "active" : "archived",
)
}
>
{archivedFilter === "archived"
? "← Back to active"
: "View archived →"}
</button>
</div>
) : (
<div className="pb-2">
<div className="mb-2 flex h-5 justify-end pr-1.5">
<FilterDropdown
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
/>
</div>
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
label={PINNED_SECTION_KEY}
count={pinnedChats.length}
expanded={!collapsedSections[PINNED_SECTION_KEY]}
onToggle={() => toggleSection(PINNED_SECTION_KEY)}
testId={getSectionToggleTestId(PINNED_SECTION_KEY)}
/>
{!collapsedSections[PINNED_SECTION_KEY] && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
key={chat.id}
chat={chat}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}
{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 (
<div key={group} className="[&:not(:first-child)]:mt-3">
<ChatSectionHeader
label={group}
count={groupChats.length}
expanded={isGroupExpanded}
onToggle={() => toggleSection(group)}
testId={getSectionToggleTestId(group)}
/>
{isGroupExpanded && (
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
)}
</div>
);
})}
</div>
)}
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext>
)}
</div>
</ScrollArea>
</div>
<UserSidebarFooter />
</div>
);
};
@@ -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<HTMLDivElement>(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 (
<div ref={sentinelRef} className="flex items-center justify-center py-2">
{isFetchingNextPage && (
<Spinner className="h-4 w-4 text-content-secondary" loading />
)}
</div>
);
};
@@ -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 (
<div className="hidden border-0 border-t border-solid sm:block">
<div className="flex items-stretch">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 bg-transparent border-0 cursor-pointer px-3 py-3 text-left hover:bg-surface-tertiary/50 transition-colors"
>
<Avatar
fallback={user.username}
src={user.avatar_url}
size="sm"
/>
<span className="truncate text-sm text-content-secondary">
{user.name || user.username}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-auto w-[260px]">
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={
appearance.support_links?.filter(
(link) => link.location !== "navbar",
) ?? []
}
onSignOut={signOut}
/>
</DropdownMenuContent>
</DropdownMenu>
<UsageIndicator />
</div>
</div>
);
};
@@ -0,0 +1 @@
export { RenameChatDialog } from "./RenameChatDialog";
@@ -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 }) => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">{label}</span>
{(adminOnly || TrailingIcon) && (
<span className="ml-auto flex items-center gap-2">
{adminOnly && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<ShieldIcon className="h-3 w-3 shrink-0 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="right">Admin only</TooltipContent>
</Tooltip>
)}
{TrailingIcon && <TrailingIcon className="h-4 w-4 shrink-0" />}
</span>
)}
</>
);
export const SettingsNavItem: FC<SettingsNavItemProps> = ({
icon,
label,
active,
adminOnly,
disabled,
trailingIcon,
...rest
}) => {
if (rest.to != null) {
return (
<Link
to={rest.to}
replace={rest.replace}
state={rest.state}
onClick={rest.onClick}
className={navItemClassName(active, disabled)}
aria-current={active ? "page" : undefined}
tabIndex={disabled ? -1 : undefined}
>
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailingIcon={trailingIcon}
/>
</Link>
);
}
return (
<button
type="button"
onClick={rest.onClick}
disabled={disabled}
className={navItemClassName(active, disabled)}
aria-current={active ? "page" : undefined}
>
<NavItemContent
icon={icon}
label={label}
adminOnly={adminOnly}
trailingIcon={trailingIcon}
/>
</button>
);
};
@@ -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<SettingsPanelProps> = ({
isSettingsPanel,
settingsPanel,
settingsSection,
showApiKeysItem,
isPersonalModelOverridesEnabled,
isAdmin,
location,
onCollapse,
}) => {
const subNavTitle =
settingsPanel === "settings-admin" ? "Manage Agents" : "Settings";
return (
<div
className={cn(
"absolute inset-0 flex flex-col sm:transition-transform sm:duration-200 sm:ease-in-out",
!isSettingsPanel && "translate-x-full",
)}
aria-hidden={!isSettingsPanel}
inert={!isSettingsPanel ? true : undefined}
>
<div className="border-b border-border-default px-2 pb-2 pt-3 sm:py-2">
<div className="relative flex items-center">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm font-medium text-content-primary">
{subNavTitle}
</span>
<Button
asChild
variant="subtle"
size="icon"
aria-label={
settingsPanel === "settings-admin"
? "Back to Settings"
: "Back to Agents"
}
className="relative z-10 h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
{settingsPanel === "settings-admin" ? (
<Link
to="/agents/settings/general"
state={location.state}
aria-label="Back to Settings"
>
<ArrowLeftIcon />
</Link>
) : (
<Link
to={(location.state as { from?: string })?.from || "/agents"}
>
<ArrowLeftIcon />
</Link>
)}
</Button>
<div className="flex-1" />
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="relative z-10 hidden h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary sm:inline-flex"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
</div>
{settingsPanel === "settings" ? (
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={UserIcon}
label="General"
active={!settingsSection || settingsSection === "general"}
to="/agents/settings/general"
state={location.state}
/>
{isPersonalModelOverridesEnabled && (
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={settingsSection === "user-agents"}
to="/agents/settings/user-agents"
state={location.state}
/>
)}
<SettingsNavItem
icon={ShrinkIcon}
label="Compaction"
active={settingsSection === "compaction"}
to="/agents/settings/compaction"
state={location.state}
/>
{showApiKeysItem && (
<SettingsNavItem
icon={KeyIcon}
label="Secrets (API keys)"
active={settingsSection === "api-keys"}
to="/agents/settings/api-keys"
state={location.state}
/>
)}
{isAdmin && (
<SettingsNavItem
icon={Settings2Icon}
label="Manage Agents"
active={false}
to="/agents/settings/admin"
state={location.state}
trailingIcon={ChevronRightIcon}
/>
)}
</nav>
) : (
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={!settingsSection || settingsSection === "agents"}
to="/agents/settings/agents"
state={location.state}
/>
<SettingsNavItem
icon={PlugIcon}
label="Providers"
active={settingsSection === "providers"}
to="/agents/settings/providers"
state={location.state}
/>
<SettingsNavItem
icon={BoxesIcon}
label="Models"
active={settingsSection === "models"}
to="/agents/settings/models"
state={location.state}
/>
<SettingsNavItem
icon={ServerIcon}
label="MCP Servers"
active={settingsSection === "mcp-servers"}
to="/agents/settings/mcp-servers"
state={location.state}
/>
<SettingsNavItem
icon={LayoutTemplateIcon}
label="Templates"
active={settingsSection === "templates"}
to="/agents/settings/templates"
state={location.state}
/>
<SettingsNavItem
icon={CoinsIcon}
label="Spend"
active={settingsSection === "spend"}
to="/agents/settings/spend"
state={location.state}
/>
<SettingsNavItem
icon={ReceiptTextIcon}
label="Instructions"
active={settingsSection === "instructions"}
to="/agents/settings/instructions"
state={location.state}
/>
<SettingsNavItem
icon={FlaskConicalIcon}
label="Experiments"
active={settingsSection === "experiments"}
to="/agents/settings/experiments"
state={location.state}
/>
<SettingsNavItem
icon={RefreshCwIcon}
label="Lifecycle"
active={settingsSection === "lifecycle"}
to="/agents/settings/lifecycle"
state={location.state}
/>
</nav>
)}
</div>
);
};
@@ -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", () => {
@@ -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<SidebarView, { panel: "settings" | "settings-admin" }> {
return view.panel === "settings" || view.panel === "settings-admin";
}
@@ -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),
),
);
}
@@ -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<SidebarTabViewProps> = ({
}) => {
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<SidebarTabViewProps> = ({
if (tabs.length === 0 && !desktopChatId) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
{/* Tab bar – always visible for the expand button. */}
<div
role="tablist"
className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-4 py-1.5 lg:px-3 lg:py-1"
@@ -193,12 +186,10 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
{/* Tab bar */}
<div
role="tablist"
className="relative flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-4 py-1.5 lg:px-3 lg:py-1"
>
{/* Back button (mobile), placed before the tab strip */}
{onClose && (
<Button
variant="subtle"
@@ -210,7 +201,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
<ArrowLeftIcon />
</Button>
)}
{/* Sidebar toggle – only when expanded and sidebar is collapsed */}
{isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
<Button
variant="subtle"
@@ -222,7 +212,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
<PanelLeftIcon />
</Button>
)}
{/* Scrollable tab strip with overlay chevrons */}
<div className="relative min-w-0 flex-1">
{canScrollLeft && (
<button
@@ -300,7 +289,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</button>
)}
</div>
{/* Center: chat title when expanded */}
{isExpanded && chatTitle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="truncate px-24 text-sm text-content-primary">
@@ -308,7 +296,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</span>
</div>
)}
{/* Expand/collapse (desktop only) */}
<Button
variant="subtle"
size="icon"
@@ -319,7 +306,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
{isExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
{/* Tab panels – all stay mounted, only the active one visible. */}
{allPanels.map((panel) => {
const isActive = effectiveTabId === panel.id;
return (
@@ -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[],
@@ -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<string, Chat>;
readonly visibleChatIDs: ReadonlySet<string>;
readonly normalizedSearch: string;
readonly expandedById: Record<string, boolean>;
readonly modelOptions: readonly ModelSelectorOption[];
readonly modelConfigs: readonly ChatModelConfig[];
readonly chatErrorReasons: Record<string, string>;
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<ChatTreeContextValue | null>(null);
export function useChatTree(): ChatTreeContextValue {
const ctx = useContext(ChatTreeContext);
if (!ctx) {
throw new Error("useChatTree must be used within ChatTreeContext.Provider");
}
return ctx;
}
@@ -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<ChatTreeNodeProps> = ({ 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<string | undefined>(
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 && (
<Item
onSelect={() =>
chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id)
}
>
{chat.pin_order > 0 ? (
<>
<PinOffIcon className="h-3.5 w-3.5" />
Unpin agent
</>
) : (
<>
<PinIcon className="h-3.5 w-3.5" />
Pin agent
</>
)}
</Item>
)}
{chat.archived ? (
<Item disabled={isArchiving} onSelect={() => onUnarchiveAgent(chat.id)}>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive agent
</Item>
) : (
<>
{onOpenRenameDialog && (
<Item onSelect={() => onOpenRenameDialog(chat)}>
<SquarePenIcon className="h-3.5 w-3.5" />
Rename chat
</Item>
)}
<Separator />
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</Item>
{workspaceId && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAndDeleteWorkspace(chat.id, workspaceId)}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & delete workspace
</Item>
)}
</>
)}
</>
);
return (
<div className="flex min-w-0 flex-col">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 select-none [@media(pointer:coarse)]:[-webkit-touch-callout:none] items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
isChildNode &&
"before:absolute before:-left-2.5 before:top-[17px] before:h-px before:w-2.5 before:bg-border-default/70",
)}
>
<div
className={cn(
"group/icon relative mt-1.5 h-5 w-5 shrink-0",
hasChildren && "cursor-pointer",
)}
>
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-md",
hasChildren &&
"[@media(hover:hover)]:group-hover/icon:invisible",
)}
>
<StatusIcon
data-testid={
isDelegatedExecuting
? `agents-tree-executing-${chat.id}`
: undefined
}
className={cn("h-3.5 w-3.5 shrink-0", config.className)}
/>
</div>
{hasChildren && (
<Button
variant="subtle"
size="icon"
onClick={() => toggleExpanded(chatID)}
className={cn(
"absolute inset-0 invisible flex h-5 w-5 min-w-0 items-center justify-center rounded-md p-0 text-content-secondary/60 hover:text-content-primary [&>svg]:size-3.5",
"[@media(hover:hover)]:group-hover/icon:visible",
)}
data-testid={`agents-tree-toggle-${chat.id}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
)}
</div>
<NavLink
to={{
pathname: `/agents/${chat.id}`,
search: location.search,
}}
className="flex min-h-0 min-w-0 flex-1 items-start gap-2 rounded-[inherit] py-1 pr-0.5 text-inherit no-underline"
>
{({ isActive }) => (
<div className="min-w-0 flex-1 overflow-hidden text-left">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
aria-busy={isRegeneratingThisChat}
className={cn(
"block flex-1 truncate text-[13px] text-content-primary",
isActive && "font-medium",
isRegeneratingThisChat && "animate-pulse",
)}
>
{chat.title}
</span>
{chat.has_unread && !isActiveChat && (
<span className="sr-only">(unread)</span>
)}
{isRegeneratingThisChat && (
<span className="sr-only" role="status">
Regenerating title…
</span>
)}
</div>
<div className="flex min-w-0 items-center gap-1.5">
{hasLinkedDiffStatus && hasLineStats && (
<span
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums"
title={`${filesChangedLabel}, +${additions} -${deletions}`}
>
<span className="text-git-added-bright">
+{additions}
</span>
<span className="text-git-deleted-bright">
&minus;{deletions}
</span>
</span>
)}
<div
className={cn(
"min-w-0 overflow-hidden text-[13px] leading-4",
errorReason
? "line-clamp-1 whitespace-normal text-content-destructive [overflow-wrap:anywhere]"
: "truncate text-content-secondary",
)}
title={subtitle}
>
{subtitle}
</div>
</div>
</div>
)}
</NavLink>
<div className="relative mt-1 flex h-6 w-7 shrink-0 items-center justify-end">
{isArchivingThisChat ? (
<Spinner
className="h-3.5 w-3.5 text-content-secondary"
loading
/>
) : (
<>
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
{chat.has_unread && !isActiveChat ? (
<span
className="h-2 w-2 shrink-0 rounded-full bg-content-link"
data-testid={`unread-indicator-${chat.id}`}
aria-hidden="true"
/>
) : (
<span data-chromatic="ignore">
{shortRelativeTime(chat.updated_at)}
</span>
)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisVerticalIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{renderMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="[&_[role=menuitem]]:text-[13px]">
{renderMenuItems({
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
})}
</ContextMenuContent>
</ContextMenu>
{hasChildren && isExpanded && (
<div className="relative ml-4 border-l border-border-default/60 pl-2.5">
{childIDs.map((childID) => {
const childChat = chatById.get(childID);
if (!childChat) return null;
return (
<ChatTreeNode key={childChat.id} chat={childChat} isChildNode />
);
})}
</div>
)}
</div>
);
};
@@ -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 (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "opacity-50")}
{...attributes}
{...listeners}
>
<ChatTreeNode chat={chat} isChildNode={false} />
</div>
);
};
@@ -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<string, Chat>;
readonly childrenById: ReadonlyMap<string, readonly string[]>;
readonly parentById: ReadonlyMap<string, string | undefined>;
};
export const getParentChatID = (chat: Chat): string | undefined => {
return asNonEmptyString(chat.parent_chat_id);
};
export const buildChatTree = (chats: readonly Chat[]): ChatTree => {
const chatById = new Map<string, Chat>();
const parentById = new Map<string, string | undefined>();
const childrenById = new Map<string, string[]>();
// 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<string> => {
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<string>();
}
const visible = new Set<string>();
for (const matchedChatID of matchedChatIDs) {
let parentCursor: string | undefined = matchedChatID;
const seenParents = new Set<string>();
while (parentCursor && !seenParents.has(parentCursor)) {
seenParents.add(parentCursor);
visible.add(parentCursor);
parentCursor = tree.parentById.get(parentCursor);
}
const stack = [matchedChatID];
const seenDescendants = new Set<string>();
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;
};
@@ -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;
};
@@ -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;
};
File diff suppressed because it is too large Load Diff