mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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;" },
|
||||
|
||||
+6
-7
@@ -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>
|
||||
);
|
||||
},
|
||||
+28
-29
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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
-1
@@ -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";
|
||||
}
|
||||
+1
-1
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
+1
-15
@@ -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 (
|
||||
-11
@@ -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">
|
||||
−{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
Reference in New Issue
Block a user