feat(ui): re-add chat UI, allow simple UI for MCP OBO auth (#31893)
Some checks are pending
GitHub Actions Security Analysis / zizmor (push) Waiting to run

This commit is contained in:
Krrish Dholakia 2026-07-03 12:36:36 -07:00 • committed by GitHub
parent 28ddad271e
commit e06adb5588
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 4521 additions and 38 deletions

View file

@ -177,6 +177,11 @@ class UISettings(BaseModel):
description="If true, org admins cannot generate API keys via /key/generate.",
)
enable_chat_ui: bool = Field(
default=False,
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -200,6 +205,7 @@ ALLOWED_UI_SETTINGS_FIELDS = {
"scope_user_search_to_org",
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
}
# Flags that must be synced from the persisted UISettings into

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 1991,
"complexity": 126,
"complexity": 128,
"max-depth": 59
}

View file

@ -34,6 +34,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"
},
@ -10488,6 +10489,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -10497,6 +10508,34 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@ -10521,6 +10560,107 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@ -10736,6 +10876,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@ -13251,6 +13512,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@ -13284,6 +13563,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",

View file

@ -50,6 +50,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"
},

View file

@ -15,6 +15,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side
const { accessToken } = useAuthorized();
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
const [enableProjectsUI, setEnableProjectsUI] = useState<boolean>(false);
const [enableChatUI, setEnableChatUI] = useState<boolean>(false);
const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState<boolean>(false);
const [allowAgentsForTeamAdmins, setAllowAgentsForTeamAdmins] = useState<boolean>(false);
const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState<boolean>(false);
@ -46,6 +47,10 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side
setEnableProjectsUI(Boolean(settings.values.enable_projects_ui));
}
if (settings?.values?.enable_chat_ui !== undefined) {
setEnableChatUI(Boolean(settings.values.enable_chat_ui));
}
if (settings?.values?.disable_agents_for_internal_users !== undefined) {
setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users));
}
@ -76,6 +81,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side
collapsed={sidebarCollapsed}
enabledPagesInternalUsers={enabledPagesInternalUsers}
enableProjectsUI={enableProjectsUI}
enableChatUI={enableChatUI}
disableAgentsForInternalUsers={disableAgentsForInternalUsers}
allowAgentsForTeamAdmins={allowAgentsForTeamAdmins}
disableVectorStoresForInternalUsers={disableVectorStoresForInternalUsers}

View file

@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import ChatPageRoute from "./page";
const { mockUseAuthorized, mockUseUISettings, mockUseUIConfig, mockReplace, state } = vi.hoisted(() => {
const state = {
userRole: "Internal User" as string,
enableChatUI: false,
isUISettingsLoading: false,
serverRootPath: undefined as string | undefined,
};
return {
state,
mockReplace: vi.fn(),
mockUseAuthorized: vi.fn(() => ({
accessToken: "token-123",
userRole: state.userRole,
userId: "user-1",
userEmail: "user@example.com",
})),
mockUseUISettings: vi.fn(() => ({
data: { values: { enable_chat_ui: state.enableChatUI } },
isLoading: state.isUISettingsLoading,
})),
mockUseUIConfig: vi.fn(() => ({ data: { server_root_path: state.serverRootPath } })),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ useUIConfig: mockUseUIConfig }));
vi.mock("@/components/chat/ChatPage", () => ({ default: () => <div data-testid="chat-page" /> }));
describe("ChatPageRoute", () => {
afterEach(() => {
state.userRole = "Internal User";
state.enableChatUI = false;
state.isUISettingsLoading = false;
state.serverRootPath = undefined;
mockReplace.mockClear();
});
it("renders the chat page when enable_chat_ui is on", () => {
state.enableChatUI = true;
render(<ChatPageRoute />);
expect(screen.getByTestId("chat-page")).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("redirects non-admins to the dashboard when enable_chat_ui is off", () => {
state.enableChatUI = false;
state.userRole = "Internal User";
render(<ChatPageRoute />);
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledWith("/ui/");
});
it("redirects admins to the dashboard when enable_chat_ui is off", () => {
state.enableChatUI = false;
state.userRole = "Admin";
render(<ChatPageRoute />);
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledWith("/ui/");
});
it("renders nothing while UI settings are still loading", () => {
state.isUISettingsLoading = true;
render(<ChatPageRoute />);
expect(screen.queryByTestId("chat-page")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("respects a custom server_root_path when redirecting", () => {
state.enableChatUI = false;
state.userRole = "Internal User";
state.serverRootPath = "/api/v1";
render(<ChatPageRoute />);
expect(mockReplace).toHaveBeenCalledWith("/api/v1/ui/");
});
});

View file

@ -0,0 +1,46 @@
"use client";
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import ChatPage from "@/components/chat/ChatPage";
// ChatPage uses useSearchParams() which requires a Suspense boundary for static export.
const ChatPageContent = () => {
const { accessToken, userRole, userId, userEmail } = useAuthorized();
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
const { data: uiConfig } = useUIConfig();
const router = useRouter();
const uiRoot =
uiConfig?.server_root_path && uiConfig.server_root_path !== "/"
? uiConfig.server_root_path.replace(/\/+$/, "")
: "";
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
const blocked = !isUISettingsLoading && !chatEnabled;
useEffect(() => {
if (blocked) router.replace(`${uiRoot}/ui/`);
}, [blocked, uiRoot, router]);
if (isUISettingsLoading || blocked) return null;
return (
<ChatPage
accessToken={accessToken ?? ""}
userRole={userRole ?? ""}
userId={userId ?? ""}
userEmail={userEmail ?? ""}
/>
);
};
const ChatPageRoute = () => (
<Suspense>
<ChatPageContent />
</Suspense>
);
export default ChatPageRoute;

View file

@ -1,32 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import ViewSwitcher from "./ViewSwitcher";
const { mockUsePluginMode, state } = vi.hoisted(() => {
const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
mode: "ai-gateway" as string,
setMode: vi.fn(),
plugins: [] as { name: string; display_name: string; url: string }[],
activePlugin: null as { name: string; display_name: string; url: string } | null,
enableChatUI: false,
};
return {
state,
mockUsePluginMode: vi.fn(() => ({
mode: state.mode,
setMode: state.setMode,
plugins: state.plugins,
activePlugin: state.activePlugin,
})),
mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } } })),
};
return { mockUsePluginMode: vi.fn(() => state), state };
});
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
// Deterministic hrefs so navigation assertions don't depend on server_root_path.
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` }));
describe("ViewSwitcher", () => {
let assignSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
assignSpy = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { assign: assignSpy },
});
});
afterEach(() => {
state.mode = "ai-gateway";
state.plugins = [];
state.enableChatUI = false;
state.setMode.mockClear();
});
it("renders nothing when there are no plugins", () => {
it("renders nothing with no plugins, chat disabled, and a non-admin user", () => {
const { container } = render(<ViewSwitcher />);
expect(container.firstChild).toBeNull();
});
it("labels the button from the active plugin's display_name and lists AI Gateway + each plugin", async () => {
it("labels the button from the active plugin and lists AI Gateway + each plugin", async () => {
state.plugins = [
{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" },
{ name: "obs", display_name: "Observability", url: "http://localhost:9000" },
@ -35,7 +59,6 @@ describe("ViewSwitcher", () => {
render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Chat UI");
expect(screen.queryByText("Agent Control Plane")).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("button"));
@ -44,22 +67,7 @@ describe("ViewSwitcher", () => {
expect(screen.getByText("Observability")).toBeInTheDocument();
});
it("switches to AI Gateway when that entry is picked", async () => {
state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }];
state.mode = "litellm-platform-plugin";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("AI Gateway")).toBeInTheDocument());
act(() => {
fireEvent.click(screen.getByText("AI Gateway"));
});
expect(state.setMode).toHaveBeenCalledWith("ai-gateway");
});
it("switches to a plugin by name when its entry is picked", async () => {
it("switches plugin mode when a mode entry is picked", async () => {
state.plugins = [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }];
state.mode = "ai-gateway";
render(<ViewSwitcher />);
@ -72,5 +80,35 @@ describe("ViewSwitcher", () => {
fireEvent.click(screen.getByText("Chat UI"));
});
expect(state.setMode).toHaveBeenCalledWith("litellm-platform-plugin");
expect(assignSpy).not.toHaveBeenCalled();
});
it("shows a clickable Chat entry that navigates to the chat app when enabled", async () => {
state.enableChatUI = true;
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("Chat")).toBeInTheDocument());
expect(screen.queryByText(/Enable in Admin Settings/i)).not.toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("Chat"));
});
expect(assignSpy).toHaveBeenCalledWith("/ui/chat");
expect(state.setMode).not.toHaveBeenCalled();
});
it("hides the Chat entry from everyone when disabled", async () => {
state.enableChatUI = false;
state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }];
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
await waitFor(() => expect(screen.getByText("Observability")).toBeInTheDocument());
expect(screen.queryByText("Chat")).not.toBeInTheDocument();
});
});

View file

@ -3,33 +3,58 @@ import { Dropdown } from "antd";
import { AppstoreOutlined, CheckOutlined, DownOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { migratedHref } from "@/utils/migratedPages";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
export default function ViewSwitcher() {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
// Only a switcher when there is at least one plugin to switch to.
if (plugins.length === 0) return null;
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
if (plugins.length === 0 && !chatEnabled) return null;
const activeLabel = plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
const entries = [
{ value: GATEWAY, label: "AI Gateway" },
...plugins.map((p) => ({ value: p.name, label: p.display_name })),
const modeEntries = [
{ key: GATEWAY, label: "AI Gateway" },
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
];
const items: MenuProps["items"] = entries.map((e) => ({
key: e.value,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{e.value === mode && <CheckOutlined className="text-blue-600" />}
</div>
),
}));
const items: MenuProps["items"] = [
...modeEntries.map((e) => ({
key: e.key,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{e.key === mode && <CheckOutlined className="text-blue-600" />}
</div>
),
})),
...(chatEnabled
? [
{
key: CHAT,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
</div>
),
},
]
: []),
];
const onClick: MenuProps["onClick"] = ({ key }) => setMode(key);
const onClick: MenuProps["onClick"] = ({ key }) => {
if (key === CHAT) {
window.location.assign(migratedHref(CHAT));
return;
}
setMode(key);
};
return (
<Dropdown menu={{ items, onClick, selectedKeys: [mode] }} trigger={["click"]}>

View file

@ -19,6 +19,7 @@ export default function UISettings() {
const forwardClientHeadersProperty = schema?.properties?.forward_client_headers_to_llm_api;
const forwardLLMProviderAuthHeadersProperty = schema?.properties?.forward_llm_provider_auth_headers;
const enableProjectsUIProperty = schema?.properties?.enable_projects_ui;
const enableChatUIProperty = schema?.properties?.enable_chat_ui;
const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users;
const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users;
const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins;
@ -114,6 +115,21 @@ export default function UISettings() {
);
};
const handleToggleEnableChatUI = (checked: boolean) => {
updateSettings(
{ enable_chat_ui: checked },
{
onSuccess: () => {
NotificationManager.success("UI settings updated successfully. Refreshing page...");
setTimeout(() => window.location.reload(), 1000);
},
onError: (error) => {
NotificationManager.fromBackend(error);
},
},
);
};
const handleToggleRequireAuthForPublicAIHub = (checked: boolean) => {
updateSettings(
{ require_auth_for_public_ai_hub: checked },
@ -335,6 +351,23 @@ export default function UISettings() {
</Space>
)}
<Space align="start" size="middle">
<Switch
checked={Boolean(values.enable_chat_ui)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleEnableChatUI}
aria-label={enableChatUIProperty?.description ?? "Enable Chat page"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>[BETA] Enable Chat page (page will refresh)</Typography.Text>
<Typography.Text type="secondary">
{enableChatUIProperty?.description ??
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Agents access control */}

View file

@ -0,0 +1,585 @@
"use client";
import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons";
import { Collapse, Tooltip } from "antd";
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import { ChatMessage } from "./types";
const { Panel } = Collapse;
// Keys whose values must be redacted in tool args display
const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i;
function redactSensitiveValues(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (REDACTED_KEY_PATTERNS.test(k)) {
result[k] = "[redacted]";
} else if (Array.isArray(v)) {
result[k] = v.map((item) =>
item !== null && typeof item === "object" && !Array.isArray(item)
? redactSensitiveValues(item as Record<string, unknown>)
: item,
);
} else if (v !== null && typeof v === "object") {
result[k] = redactSensitiveValues(v as Record<string, unknown>);
} else {
result[k] = v;
}
}
return result;
}
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
// Shared markdown code renderer matching ReasoningContent style.
// react-markdown v9 removed the `inline` prop; detect fenced blocks via language className.
function MarkdownCodeRenderer({
node,
className,
children,
...props
}: React.ComponentPropsWithoutRef<"code"> & { node?: unknown }) {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
style={coy as Record<string, React.CSSProperties>}
language={match[1]}
PreTag="div"
className="rounded-md my-2"
{...(props as Record<string, unknown>)}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={`${className ?? ""} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`} {...props}>
{children}
</code>
);
}
// ------- Sub-components -------
interface UserBubbleProps {
message: ChatMessage;
onEdit?: (messageId: string, newContent: string) => void;
isStreaming?: boolean;
}
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
const [hovered, setHovered] = useState(false);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.value.length;
}
}, [editing]);
// Auto-resize textarea
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [editValue, editing]);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== message.content && onEdit) {
onEdit(message.id, trimmed);
}
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSave();
}
if (e.key === "Escape") {
setEditValue(message.content);
setEditing(false);
}
};
if (editing) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
<div
style={{
width: "72%",
background: "#fff",
border: "1.5px solid #1677ff",
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 0 0 3px rgba(22,119,255,0.1)",
}}
>
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
style={{
width: "100%",
padding: "10px 14px",
border: "none",
outline: "none",
resize: "none",
fontSize: 14,
lineHeight: "1.6",
color: "#111827",
fontFamily: "inherit",
background: "transparent",
boxSizing: "border-box",
minHeight: 40,
}}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
padding: "6px 10px 8px",
borderTop: "1px solid #f0f0f0",
}}
>
<button
onClick={() => {
setEditValue(message.content);
setEditing(false);
}}
style={{
padding: "4px 12px",
borderRadius: 6,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 13,
cursor: "pointer",
}}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!editValue.trim()}
style={{
padding: "4px 12px",
borderRadius: 6,
border: "none",
background: editValue.trim() ? "#1677ff" : "#f3f4f6",
color: editValue.trim() ? "#fff" : "#9ca3af",
fontSize: 13,
fontWeight: 500,
cursor: editValue.trim() ? "pointer" : "not-allowed",
}}
>
Save &amp; Send
</button>
</div>
</div>
</div>
);
}
return (
<div
style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", width: "100%" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "72%" }}>
{/* Edit button — appears on hover, to the left of the bubble */}
{hovered && !isStreaming && onEdit && (
<Tooltip title="Edit message">
<button
onClick={() => {
setEditValue(message.content);
setEditing(true);
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px 6px",
borderRadius: 5,
color: "#9ca3af",
fontSize: 13,
flexShrink: 0,
display: "flex",
alignItems: "center",
transition: "color 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
}}
>
<EditOutlined />
</button>
</Tooltip>
)}
<div
style={{
backgroundColor: "#f0f2f5",
borderRadius: 16,
padding: "10px 14px",
fontSize: 14,
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#111827",
}}
>
{message.content}
</div>
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>{formatTimestamp(message.timestamp)}</span>
</div>
);
}
interface AssistantBubbleProps {
message: ChatMessage;
isLastMessage: boolean;
isStreaming: boolean;
isTypingIndicator: boolean;
/** MCP events stored on the message — rendered inline below the response. */
mcpEvents?: ChatMessage["mcpEvents"];
}
function AssistantBubble({ message, isLastMessage, isStreaming, isTypingIndicator, mcpEvents }: AssistantBubbleProps) {
// ReasoningContent manages its own expanded state; we bump this key to
// remount it (collapsed by default) when streaming finishes.
const [reasoningKey, setReasoningKey] = useState(0);
const prevStreamingRef = useRef<boolean>(isStreaming);
useEffect(() => {
if (prevStreamingRef.current && !isStreaming) {
setReasoningKey((k) => k + 1);
}
prevStreamingRef.current = isStreaming;
}, [isStreaming]);
const showReasoningPlaceholder = isLastMessage && isStreaming && !message.reasoningContent;
const showReasoning = !!message.reasoningContent || showReasoningPlaceholder;
if (isTypingIndicator) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "10px 4px" }}>
<TypingDots />
</div>
</div>
);
}
// Split content at trailing "[stopped]"
let mainContent = message.content;
let stoppedSuffix = false;
if (mainContent.endsWith("[stopped]")) {
mainContent = mainContent.slice(0, -"[stopped]".length);
stoppedSuffix = true;
}
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", maxWidth: "80%" }}>
{showReasoning &&
(showReasoningPlaceholder ? (
<ThinkingPlaceholder />
) : (
<ReasoningContent key={reasoningKey} reasoningContent={message.reasoningContent!} />
))}
<div
style={{
fontSize: 14,
lineHeight: "1.7",
color: "#111827",
wordBreak: "break-word",
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: MarkdownCodeRenderer as React.ComponentType<React.ComponentPropsWithoutRef<"code">>,
}}
>
{mainContent}
</ReactMarkdown>
{stoppedSuffix && <span style={{ color: "#9ca3af", fontStyle: "italic" }}> [stopped]</span>}
</div>
<CopyButton text={mainContent} />
{mcpEvents && mcpEvents.length > 0 && (
<div style={{ marginTop: 8, maxWidth: "100%" }}>
<MCPEventsDisplay events={mcpEvents} />
</div>
)}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
// clipboard not available (non-HTTPS or permission denied) — silently no-op
});
};
return (
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 6 }}>
<Tooltip title={copied ? "Copied!" : "Copy"}>
<button
onClick={handleCopy}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px 6px",
borderRadius: 5,
color: copied ? "#52c41a" : "#9ca3af",
fontSize: 13,
display: "flex",
alignItems: "center",
gap: 4,
transition: "color 0.15s",
}}
onMouseEnter={(e) => {
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
}}
onMouseLeave={(e) => {
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
}}
>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</button>
</Tooltip>
</div>
);
}
function ThinkingPlaceholder() {
return (
<>
<style>{`
@keyframes thinking-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.chat-thinking-text {
animation: thinking-pulse 1.4s ease-in-out infinite;
}
`}</style>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
marginBottom: 8,
backgroundColor: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 8,
fontSize: 12,
color: "#6b7280",
}}
>
<span className="chat-thinking-text">Thinking...</span>
</div>
</>
);
}
function TypingDots() {
return (
<>
<style>{`
@keyframes chat-typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
.chat-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background-color: #9ca3af;
animation: chat-typing-bounce 1.2s ease-in-out infinite;
}
.chat-dot:nth-child(2) { animation-delay: 0.2s; }
.chat-dot:nth-child(3) { animation-delay: 0.4s; }
`}</style>
<div className="chat-dot" />
<div className="chat-dot" />
<div className="chat-dot" />
</>
);
}
interface ToolCardProps {
message: ChatMessage;
}
function ToolCard({ message }: ToolCardProps) {
const redactedArgs = message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined;
return (
<div style={{ maxWidth: "80%" }}>
<Collapse
size="small"
style={{
backgroundColor: "#fafafa",
border: "1px solid #e5e7eb",
borderRadius: 8,
}}
>
<Panel
header={
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
<ToolOutlined style={{ color: "#6b7280" }} />
<span style={{ color: "#374151", fontWeight: 500 }}>{message.toolName ?? "Tool call"}</span>
</span>
}
key="tool"
>
{redactedArgs !== undefined && (
<div style={{ marginBottom: message.toolResult ? 12 : 0 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "#9ca3af",
marginBottom: 4,
}}
>
Arguments
</div>
<pre
style={{
margin: 0,
padding: "8px 10px",
backgroundColor: "#f3f4f6",
borderRadius: 6,
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#374151",
}}
>
{JSON.stringify(redactedArgs, null, 2)}
</pre>
</div>
)}
{message.toolResult && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "#9ca3af",
marginBottom: 4,
}}
>
Result
</div>
<div
style={{
fontSize: 13,
color: "#374151",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
}}
>
{message.toolResult}
</div>
</div>
)}
</Panel>
</Collapse>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>{formatTimestamp(message.timestamp)}</div>
</div>
);
}
// ------- Main component -------
interface Props {
messages: ChatMessage[];
isStreaming: boolean;
onEditMessage?: (messageId: string, newContent: string) => void;
}
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
// Scrolling is managed by ChatPage.tsx (scroll lock during streaming,
// scroll-to-bottom on new message). No auto-scroll here.
const lastIndex = messages.length - 1;
const lastMsg = messages[lastIndex] ?? null;
const isTypingIndicator = isStreaming && lastMsg !== null && lastMsg.role === "assistant" && lastMsg.content === "";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{messages.map((msg, idx) => {
const isLastMessage = idx === lastIndex;
if (msg.role === "user") {
return <UserBubble key={msg.id} message={msg} onEdit={onEditMessage} isStreaming={isStreaming} />;
}
if (msg.role === "tool") {
return <ToolCard key={msg.id} message={msg} />;
}
// assistant
return (
<AssistantBubble
key={msg.id}
message={msg}
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isTypingIndicator}
mcpEvents={msg.mcpEvents}
/>
);
})}
</div>
);
};
export default ChatMessages;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,452 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Button, Input, Modal, Popconfirm, Tooltip, Avatar, Typography } from "antd";
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
SearchOutlined,
UserOutlined,
MessageOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { Conversation } from "./types";
const { Text } = Typography;
interface Props {
conversations: Conversation[];
activeConversationId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onNewChat: () => void;
onRename: (id: string, newTitle: string) => void;
}
// ---- Date grouping helpers ----
type DateGroup = "Today" | "Yesterday" | "Last 7 Days" | "Older";
const getDateGroup = (timestamp: number): DateGroup => {
const now = dayjs();
const date = dayjs(timestamp);
if (date.isSame(now, "day")) return "Today";
if (date.isSame(now.subtract(1, "day"), "day")) return "Yesterday";
if (date.isAfter(now.subtract(7, "day"))) return "Last 7 Days";
return "Older";
};
const DATE_GROUP_ORDER: DateGroup[] = ["Today", "Yesterday", "Last 7 Days", "Older"];
interface GroupedConversations {
group: DateGroup;
items: Conversation[];
}
const groupConversations = (conversations: Conversation[]): GroupedConversations[] => {
const map = new Map<DateGroup, Conversation[]>();
for (const conv of conversations) {
const group = getDateGroup(conv.updatedAt);
if (!map.has(group)) map.set(group, []);
map.get(group)!.push(conv);
}
return DATE_GROUP_ORDER.filter((g) => map.has(g)).map((g) => ({
group: g,
items: map.get(g)!,
}));
};
// ---- Single conversation row ----
interface ConversationRowProps {
conv: Conversation;
isActive: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newTitle: string) => void;
}
const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSelect, onDelete, onRename }) => {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(conv.title);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const startEditing = (e: React.MouseEvent) => {
e.stopPropagation();
setEditValue(conv.title);
setEditing(true);
};
const commitRename = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== conv.title) {
onRename(conv.id, trimmed);
}
setEditing(false);
};
const cancelEditing = () => {
setEditValue(conv.title);
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
};
const truncatedTitle = conv.title.length > 40 ? conv.title.slice(0, 40) + "…" : conv.title;
return (
<div
onClick={() => !editing && onSelect(conv.id)}
className="conversation-row group"
style={{
display: "flex",
alignItems: "center",
padding: "6px 8px",
borderRadius: 6,
cursor: editing ? "default" : "pointer",
backgroundColor: isActive ? "#e6f4ff" : "transparent",
transition: "background-color 0.15s",
minHeight: 34,
position: "relative",
}}
onMouseEnter={(e) => {
if (!isActive) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f5f5f5";
}
}}
onMouseLeave={(e) => {
if (!isActive) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
}
}}
>
{editing ? (
<Input
ref={(node) => {
inputRef.current = node?.input ?? null;
}}
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, fontSize: 13 }}
/>
) : (
<>
<Text
style={{
flex: 1,
fontSize: 13,
color: isActive ? "#1677ff" : "#333",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
fontWeight: isActive ? 500 : 400,
}}
title={conv.title}
>
{truncatedTitle}
</Text>
{/* Action icons — visible only on hover via CSS opacity */}
<div
className="conversation-actions"
style={{
display: "flex",
gap: 2,
opacity: 0,
transition: "opacity 0.15s",
flexShrink: 0,
}}
onClick={(e) => e.stopPropagation()}
>
<Tooltip title="Rename">
<Button
type="text"
size="small"
icon={<EditOutlined style={{ fontSize: 12 }} />}
onClick={startEditing}
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
/>
</Tooltip>
<Popconfirm
title="Delete this conversation?"
onConfirm={() => onDelete(conv.id)}
okText="Delete"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Tooltip title="Delete">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined style={{ fontSize: 12 }} />}
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
/>
</Tooltip>
</Popconfirm>
</div>
</>
)}
</div>
);
};
// ---- Cmd+K search modal ----
interface SearchModalProps {
open: boolean;
conversations: Conversation[];
onSelect: (id: string) => void;
onClose: () => void;
}
const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect, onClose }) => {
const [query, setQuery] = useState("");
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setQuery("");
}
const filtered = query.trim()
? conversations.filter((c) => c.title.toLowerCase().includes(query.trim().toLowerCase()))
: conversations;
const handleSelect = (id: string) => {
onSelect(id);
onClose();
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={null}
width={480}
styles={{ body: { padding: "16px 16px 8px" } }}
>
<Input
autoFocus
prefix={<SearchOutlined style={{ color: "#bbb" }} />}
placeholder="Search conversations…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12 }}
allowClear
/>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: "24px 0", color: "#999" }}>No conversations found</div>
) : (
filtered.map((conv) => {
const truncated = conv.title.length > 55 ? conv.title.slice(0, 55) + "…" : conv.title;
return (
<div
key={conv.id}
onClick={() => handleSelect(conv.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background-color 0.1s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f0f5ff";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
}}
>
<MessageOutlined style={{ color: "#999", flexShrink: 0 }} />
<Text style={{ fontSize: 13 }}>{truncated}</Text>
<Text type="secondary" style={{ fontSize: 11, marginLeft: "auto", flexShrink: 0 }}>
{dayjs(conv.updatedAt).format("MMM D")}
</Text>
</div>
);
})
)}
</div>
</Modal>
);
};
// ---- Main ConversationList component ----
const ConversationList: React.FC<Props> = ({
conversations,
activeConversationId,
onSelect,
onDelete,
onNewChat,
onRename,
}) => {
const [searchModalOpen, setSearchModalOpen] = useState(false);
// Cmd+K / Ctrl+K listener
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setSearchModalOpen((prev) => !prev);
}
}, []);
useEffect(() => {
document.addEventListener("keydown", handleGlobalKeyDown);
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
}, [handleGlobalKeyDown]);
const grouped = groupConversations(conversations);
return (
<>
{/* Hover-reveal CSS for action icons */}
<style>{`
.conversation-row:hover .conversation-actions {
opacity: 1 !important;
}
`}</style>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{/* Top: New Chat button */}
<div style={{ padding: "12px 10px 8px" }}>
<Tooltip
title="Chats are saved locally in this browser. All requests are logged in Spend → Logs."
placement="right"
>
<Button type="primary" icon={<PlusOutlined />} onClick={onNewChat} style={{ width: "100%" }}>
New Chat
</Button>
</Tooltip>
</div>
{/* Conversation list (scrollable) */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "0 6px",
}}
>
{grouped.length === 0 ? (
<div
style={{
textAlign: "center",
color: "#bbb",
fontSize: 12,
marginTop: 32,
padding: "0 12px",
}}
>
No conversations yet.
<br />
Start a new chat above.
</div>
) : (
grouped.map(({ group, items }) => (
<div key={group} style={{ marginBottom: 8 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#999",
textTransform: "uppercase",
letterSpacing: "0.04em",
padding: "8px 8px 4px",
}}
>
{group}
</div>
{items.map((conv) => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onSelect={onSelect}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
))
)}
</div>
{/* Bottom: user avatar placeholder */}
<div
style={{
padding: "10px 12px",
borderTop: "1px solid #f0f0f0",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Avatar
size={28}
icon={<UserOutlined />}
style={{ backgroundColor: "#e0e7ff", color: "#4f46e5", flexShrink: 0 }}
/>
<Text
style={{
fontSize: 13,
color: "#555",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
My Account
</Text>
</div>
</div>
{/* Cmd+K search modal */}
<SearchModal
open={searchModalOpen}
conversations={conversations}
onSelect={onSelect}
onClose={() => setSearchModalOpen(false)}
/>
</>
);
};
export default ConversationList;

View file

@ -0,0 +1,718 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Spin, Input, Button, Skeleton } from "antd";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined, ToolOutlined, CheckCircleOutlined } from "@ant-design/icons";
import {
deleteMCPOAuthUserCredential,
fetchMCPServers,
getMCPOAuthUserCredentialStatus,
listMCPTools,
} from "../networking";
import { AUTH_TYPE, MCPServer, MCPTool, handleTransport } from "../mcp_tools/types";
import MessageManager from "@/components/molecules/message_manager";
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
// ── OAuth2 connect button ─────────────────────────────────────────────────────
// Wraps useUserMcpOAuthFlow so each server card can hold its own hook instance.
interface OAuth2ConnectButtonProps {
server: MCPServer;
accessToken: string;
onConnect: (serverId: string) => void;
/** "badge" = small inline chip (grid card), "button" = full Ant Button (detail view) */
variant?: "badge" | "button";
}
const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
server,
accessToken,
onConnect,
variant = "badge",
}) => {
const name = server.server_name ?? server.alias ?? server.server_id;
const { startOAuthFlow, status } = useUserMcpOAuthFlow({
accessToken,
serverId: server.server_id,
serverAlias: name,
onSuccess: useCallback(() => onConnect(server.server_id), [onConnect, server.server_id]),
});
const loading = status === "authorizing" || status === "exchanging";
if (variant === "button") {
return (
<Button
type="primary"
loading={loading}
onClick={startOAuthFlow}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{loading ? "Connecting…" : "Connect"}
</Button>
);
}
return (
<span
onClick={(e) => {
e.stopPropagation();
if (!loading) startOAuthFlow();
}}
style={{
fontSize: 11,
fontWeight: 600,
color: loading ? "#9ca3af" : "#fff",
background: loading ? "#e5e7eb" : "#1677ff",
borderRadius: 6,
padding: "2px 8px",
cursor: loading ? "default" : "pointer",
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{loading ? "Connecting…" : "Connect"}
</span>
);
};
// ─────────────────────────────────────────────────────────────────────────────
interface Props {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
}
const AVATAR_COLORS = [
"#1677ff",
"#52c41a",
"#fa8c16",
"#eb2f96",
"#722ed1",
"#13c2c2",
"#fa541c",
"#2f54eb",
"#a0d911",
"#faad14",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
type TabKey = "all" | "connected";
const TOOLS_FETCH_CONCURRENCY = 5;
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
const [detailServer, setDetailServer] = useState<MCPServer | null>(null);
// tool counts per server name, preloaded in background
const [toolCounts, setToolCounts] = useState<Record<string, number>>({});
const [loadingCounts, setLoadingCounts] = useState(false);
// OAuth2 connect state — tracks which server_ids have a stored user credential
const [oauthConnected, setOauthConnected] = useState<Set<string>>(new Set());
// Refs keep the latest values for the auto-enable effect so it always reads
// the current servers/selectedServers/onChange without needing them as
// dependencies (which would cause the effect to fire on every render).
const serversRef = useRef<MCPServer[]>([]);
useEffect(() => {
serversRef.current = servers;
}, [servers]);
const selectedServersRef = useRef<string[]>(selectedServers);
useEffect(() => {
selectedServersRef.current = selectedServers;
}, [selectedServers]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
const fetchLoadCancelledRef = useRef(false);
const fetchToolCount = useCallback(
async (server: MCPServer) => {
try {
const toolsData = await listMCPTools(accessToken, server.server_id);
if (fetchLoadCancelledRef.current) return;
const tools: MCPTool[] = Array.isArray(toolsData?.tools) ? toolsData.tools : [];
setToolCounts((prev) => ({ ...prev, [nameOf(server)]: tools.length }));
} catch {
// ignore — this server's tool count just stays unset
}
},
[accessToken],
);
const checkOauthCredential = useCallback(
async (server: MCPServer) => {
try {
const status = await getMCPOAuthUserCredentialStatus(accessToken, server.server_id);
if (fetchLoadCancelledRef.current) return;
if (status.has_credential && !status.is_expired) {
setOauthConnected((prev) => new Set(prev).add(server.server_id));
}
} catch {
// ignore — server just shows as not connected
}
},
[accessToken],
);
useEffect(() => {
fetchLoadCancelledRef.current = false;
// 1. Load servers first — show the list immediately
fetchMCPServers(accessToken)
.then(async (serverData) => {
if (fetchLoadCancelledRef.current) return;
const list: MCPServer[] = Array.isArray(serverData) ? serverData : serverData?.data ?? [];
setServers(list);
setLoading(false);
// 2. Fetch tools per server, at most TOOLS_FETCH_CONCURRENCY at a time
setLoadingCounts(true);
const chunks = Array.from({ length: Math.ceil(list.length / TOOLS_FETCH_CONCURRENCY) }, (_, i) =>
list.slice(i * TOOLS_FETCH_CONCURRENCY, (i + 1) * TOOLS_FETCH_CONCURRENCY),
);
for (const chunk of chunks) {
if (fetchLoadCancelledRef.current) return;
await Promise.allSettled(chunk.map((s) => fetchToolCount(s)));
}
if (!fetchLoadCancelledRef.current) setLoadingCounts(false);
// 3. Check OAuth credential status for OAuth2 servers in parallel
const oauthServers = list.filter((s) => s.auth_type === AUTH_TYPE.OAUTH2);
oauthServers.forEach((s) => checkOauthCredential(s));
})
.catch(() => {
if (!fetchLoadCancelledRef.current) {
setServers([]);
setLoading(false);
}
});
return () => {
fetchLoadCancelledRef.current = true;
};
}, [accessToken, fetchToolCount, checkOauthCredential]);
// Auto-enable oauth2 servers for the current chat session when a valid
// credential is detected (either on mount or after a fresh OAuth sign-in).
// Uses refs for servers/selectedServers/onChange to avoid stale closures
// without adding them as dependencies (which would re-fire on every render).
useEffect(() => {
if (oauthConnected.size === 0) return;
const namesToAdd = serversRef.current
.filter((s) => oauthConnected.has(s.server_id) && !selectedServersRef.current.includes(nameOf(s)))
.map(nameOf);
if (namesToAdd.length > 0) {
onChangeRef.current([...selectedServersRef.current, ...namesToAdd]);
}
}, [oauthConnected]);
const handleToggle = async (serverName: string, checked: boolean, serverId?: string) => {
if (!checked) {
onChange(selectedServers.filter((s) => s !== serverName));
// Also clear from oauthConnected so the auto-enable effect doesn't re-add it.
if (serverId) {
setOauthConnected((prev) => {
const next = new Set(prev);
next.delete(serverId);
return next;
});
}
return;
}
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
// Use UUID if available, fall back to name (for connectivity check only)
const idToFetch = serverId ?? serverName;
const result = await listMCPTools(accessToken, idToFetch);
if (result?.error) {
MessageManager.warning(`Could not load tools for ${serverName}`);
return;
}
// Use the ref so we read the most up-to-date list; guard against duplicates
// that the oauthConnected effect may have already added while we awaited.
if (!selectedServersRef.current.includes(serverName)) {
onChange([...selectedServersRef.current, serverName]);
}
} catch {
MessageManager.warning(`Could not load tools for ${serverName}`);
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
};
// Fetch tools for the detail view — server_id must be the UUID
const { data: detailToolsResult, isLoading: loadingTools } = useQuery({
queryKey: ["mcp-apps-panel-detail-tools", detailServer?.server_id],
queryFn: () => listMCPTools(accessToken, detailServer!.server_id),
enabled: !!detailServer,
});
const detailTools: MCPTool[] = Array.isArray(detailToolsResult?.tools) ? detailToolsResult.tools : [];
const filtered = servers.filter((s) => {
const name = nameOf(s);
const matchesQuery =
!query.trim() ||
name.toLowerCase().includes(query.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
const matchesTab = activeTab === "all" || selectedServers.includes(name);
return matchesQuery && matchesTab;
});
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
// Total tools available across all servers (based on preloaded counts)
const totalTools = Object.values(toolCounts).reduce((sum, n) => sum + n, 0);
// ── Detail view ──
if (detailServer) {
const name = nameOf(detailServer);
const isConnected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
const color = getAvatarColor(name);
return (
<div style={{ width: "100%" }}>
{/* Back */}
<button
onClick={() => setDetailServer(null)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "none",
border: "none",
cursor: "pointer",
color: "#6b7280",
fontSize: 13,
padding: "0 0 20px 0",
}}
>
<ArrowLeftOutlined style={{ fontSize: 12 }} />
Back
</button>
{/* Avatar + name + connect */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
{detailServer.mcp_info?.logo_url ? (
<img
src={detailServer.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 64,
height: 64,
borderRadius: 16,
objectFit: "contain",
flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div
style={{
width: 64,
height: 64,
borderRadius: 16,
background: color,
display: detailServer.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: 700,
fontSize: 28,
flexShrink: 0,
}}
>
{name.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: "0 0 4px", fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>{detailServer.description ?? "MCP server"}</p>
</div>
{detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(detailServer.server_id) ? (
<Button
type="default"
danger
onClick={async () => {
try {
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
} catch (_) {
// Ignore — credential may already be gone; update UI regardless.
}
setOauthConnected((prev) => {
const n = new Set(prev);
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
Disconnect
</Button>
) : (
<OAuth2ConnectButton
server={detailServer}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="button"
/>
)
) : (
<Button
type={isConnected ? "default" : "primary"}
loading={isTogglingOn}
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
)}
</div>
{/* Info table */}
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>Information</h3>
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden", marginBottom: 28 }}>
{[
["Server ID", detailServer.server_id],
["Transport", handleTransport(detailServer.transport, detailServer.spec_path)],
["Status", isConnected ? "Connected" : "Not connected"],
]
.filter(([, v]) => v)
.map(([label, value], i, arr) => (
<div
key={label}
style={{
display: "flex",
padding: "12px 16px",
borderBottom: i < arr.length - 1 ? "1px solid #f3f4f6" : "none",
fontSize: 13,
}}
>
<span style={{ width: 140, color: "#9ca3af", flexShrink: 0 }}>{label}</span>
<span style={{ color: "#111827", fontWeight: 500 }}>{value}</span>
</div>
))}
</div>
{/* Tools section */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: "#111827" }}>Available Tools</h3>
{!loadingTools && (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
background: "#f3f4f6",
borderRadius: 4,
padding: "1px 6px",
}}
>
{detailTools.length}
</span>
)}
</div>
{loadingTools ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin size="small" />
</div>
) : detailTools.length === 0 ? (
<div style={{ color: "#9ca3af", fontSize: 13, padding: "8px 0" }}>No tools available</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{detailTools.map((tool) => (
<div
key={tool.name}
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "10px 14px",
background: "#fafafa",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: tool.description ? 4 : 0 }}>
<ToolOutlined style={{ fontSize: 13, color: "#6b7280" }} />
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827", fontFamily: "monospace" }}>
{tool.name}
</span>
</div>
{tool.description && (
<p style={{ margin: 0, fontSize: 12, color: "#6b7280", paddingLeft: 21 }}>{tool.description}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
// ── List view ──
return (
<div style={{ width: "100%" }}>
{/* Header row */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
gap: 16,
flexWrap: "wrap",
}}
>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>MCP Servers</h2>
<span
style={{
fontSize: 10,
fontWeight: 600,
color: "#1677ff",
background: "#e8f4ff",
borderRadius: 4,
padding: "1px 6px",
letterSpacing: "0.05em",
textTransform: "uppercase",
}}
>
Beta
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<p style={{ margin: 0, fontSize: 13, color: "#6b7280" }}>
Browse tools, authenticate once, use in chat — no setup needed.
</p>
{loadingCounts ? (
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 12, color: "#9ca3af" }}>
<Spin size="small" style={{ transform: "scale(0.7)" }} />
Loading tools...
</span>
) : totalTools > 0 ? (
<span style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "#6b7280" }}>
<ToolOutlined style={{ fontSize: 11 }} />
{totalTools} tool{totalTools !== 1 ? "s" : ""} available
</span>
) : null}
</div>
</div>
<Input
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 13 }} />}
placeholder="Search servers..."
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
style={{ width: 220, borderRadius: 8, fontSize: 13 }}
size="middle"
/>
</div>
{/* Tabs */}
<div style={{ display: "flex", borderBottom: "1px solid #e5e7eb", marginBottom: 16 }}>
{(["all", "connected"] as TabKey[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: "8px 16px",
border: "none",
borderBottom: activeTab === tab ? "2px solid #1677ff" : "2px solid transparent",
cursor: "pointer",
fontSize: 13,
fontWeight: activeTab === tab ? 600 : 400,
background: "transparent",
color: activeTab === tab ? "#1677ff" : "#6b7280",
marginBottom: -1,
}}
>
{tab === "all" ? "All" : `Connected${connectedCount > 0 ? ` (${connectedCount})` : ""}`}
</button>
))}
</div>
{/* Grid */}
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "48px 0" }}>
<Spin />
</div>
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, padding: "48px 12px" }}>
{servers.length === 0
? "No MCP servers configured. Add servers in Tools → MCP Servers."
: activeTab === "connected"
? "No servers connected yet."
: "No servers match your search."}
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 0,
border: "1px solid #e5e7eb",
borderRadius: 10,
overflow: "hidden",
}}
>
{filtered.map((server, idx) => {
const name = nameOf(server);
const isConnected = selectedServers.includes(name);
const color = getAvatarColor(name);
const isLeftCol = idx % 2 === 0;
const count = toolCounts[name];
return (
<div
key={server.server_id}
onClick={() => setDetailServer(server)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "14px 16px",
background: "#fff",
borderRight: isLeftCol ? "1px solid #f3f4f6" : "none",
borderBottom:
Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "1px solid #f3f4f6" : "none",
cursor: "pointer",
minWidth: 0,
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background = "#fafafa";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = "#fff";
}}
>
{server.mcp_info?.logo_url ? (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 38,
height: 38,
borderRadius: 10,
objectFit: "contain",
flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div
style={{
width: 38,
height: 38,
borderRadius: 10,
background: color,
display: server.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: 700,
fontSize: 16,
flexShrink: 0,
}}
>
{name.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "#111827",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</div>
<div
style={{
fontSize: 12,
color: "#9ca3af",
marginTop: 1,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{server.description ?? "MCP server"}
</span>
{count !== undefined ? (
count > 0 ? (
<span
style={{ flexShrink: 0, display: "flex", alignItems: "center", gap: 3, color: "#9ca3af" }}
>
· <ToolOutlined style={{ fontSize: 10 }} /> {count}
</span>
) : null
) : loadingCounts ? (
<Skeleton.Input
active
size="small"
style={{ width: 28, height: 12, minWidth: 28, flexShrink: 0 }}
/>
) : null}
</div>
</div>
{server.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(server.server_id) ? (
<CheckCircleOutlined style={{ fontSize: 14, color: "#52c41a", flexShrink: 0 }} />
) : (
<OAuth2ConnectButton
server={server}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="badge"
/>
)
) : isConnected ? (
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#1677ff", flexShrink: 0 }} />
) : null}
<RightOutlined style={{ fontSize: 11, color: "#d1d5db", flexShrink: 0 }} />
</div>
);
})}
</div>
)}
</div>
);
};
export default MCPAppsPanel;

View file

@ -0,0 +1,171 @@
import React, { useEffect, useState } from "react";
import { Switch, Spin } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
interface Props {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
// Track which individual servers are being toggled on (verifying tools)
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoadingServers(true);
try {
const data = await fetchMCPServers(accessToken);
if (cancelled) return;
// API returns { data: MCPServer[] } or MCPServer[]
const list: MCPServer[] = Array.isArray(data) ? data : data?.data ?? [];
setServers(list);
} catch {
if (!cancelled) {
setServers([]);
}
} finally {
if (!cancelled) {
setLoadingServers(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [accessToken]);
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
// Toggle OFF — remove immediately, no tool fetch needed
onChange(selectedServers.filter((s) => s !== serverName));
return;
}
// Toggle ON — verify tools are reachable first
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const result = await listMCPTools(accessToken, serverName);
// listMCPTools never throws; it returns { tools, error, message } on failure
if (result?.error) {
MessageManager.warning(`Could not load tools for ${serverName} — it will be excluded from this message.`);
// Do not add to selectedServers
return;
}
onChange([...selectedServers, serverName]);
} catch {
MessageManager.warning(`Could not load tools for ${serverName} — it will be excluded from this message.`);
// Do not add to selectedServers
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
};
return (
<div
style={{
maxWidth: 320,
maxHeight: 400,
overflowY: "auto",
padding: "8px 0",
}}
>
{loadingServers ? (
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
<Spin />
</div>
) : servers.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#8c8c8c", fontSize: 13, textAlign: "center" }}>
No MCP servers configured
</div>
) : (
servers.map((server) => {
const name = server.server_name ?? server.alias ?? server.server_id;
const isSelected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
return (
<div
key={server.server_id}
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "8px 12px",
gap: 12,
}}
>
{server.mcp_info?.logo_url && (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 24,
height: 24,
borderRadius: 6,
objectFit: "contain",
flexShrink: 0,
marginTop: 1,
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
fontSize: 13,
color: "#1f1f1f",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{name}
</div>
{server.description && (
<div
style={{
fontSize: 12,
color: "#8c8c8c",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{server.description}
</div>
)}
</div>
<Switch
size="small"
checked={isSelected}
loading={isTogglingOn}
onChange={(checked) => handleToggle(name, checked)}
/>
</div>
);
})
)}
</div>
);
};
export default MCPConnectPicker;

View file

@ -0,0 +1,157 @@
"use client";
/**
* MCPCredentialsTab
*
* Shows all OAuth2 MCP connections the calling user has stored.
* Lives in the Chat sidebar's "Credentials" tab.
*/
import React, { useState } from "react";
import { Spin, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import MessageManager from "@/components/molecules/message_manager";
import { DeleteOutlined, LinkOutlined } from "@ant-design/icons";
import { deleteMCPOAuthUserCredential, listMCPUserCredentials, MCPUserCredentialListItem } from "../networking";
const MCP_CREDENTIALS_QUERY_KEY = "mcp-user-credentials";
interface Props {
accessToken: string;
}
function relativeTime(isoString: string | null | undefined): string {
if (!isoString) return "";
try {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return "just now";
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
} catch {
return "";
}
}
function expiryLabel(isoString: string | null | undefined): string {
if (!isoString) return "Does not expire";
try {
const exp = new Date(isoString);
const diffMs = exp.getTime() - Date.now();
if (diffMs <= 0) return "Expired";
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffDay > 0) return `Expires in ${diffDay}d`;
if (diffHr > 0) return `Expires in ${diffHr}h`;
return `Expires in ${diffMin}m`;
} catch {
return "";
}
}
const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
const queryClient = useQueryClient();
const [revoking, setRevoking] = useState<Set<string>>(new Set());
const { data: credentials = [], isLoading: loading } = useQuery({
queryKey: [MCP_CREDENTIALS_QUERY_KEY, accessToken],
queryFn: () => listMCPUserCredentials(accessToken),
enabled: !!accessToken,
});
const handleRevoke = async (serverId: string) => {
setRevoking((prev) => new Set(prev).add(serverId));
try {
await deleteMCPOAuthUserCredential(accessToken, serverId);
queryClient.setQueryData<MCPUserCredentialListItem[]>([MCP_CREDENTIALS_QUERY_KEY, accessToken], (prev) =>
(prev ?? []).filter((c) => c.server_id !== serverId),
);
} catch {
MessageManager.error("Failed to revoke connection. Please try again.");
} finally {
setRevoking((prev) => {
const n = new Set(prev);
n.delete(serverId);
return n;
});
}
};
const displayName = (c: MCPUserCredentialListItem) => c.alias || c.server_name || c.server_id;
const columns: ColumnsType<MCPUserCredentialListItem> = [
{
title: "App",
key: "app",
render: (_, cred) => <span className="text-sm font-medium text-gray-900">{displayName(cred)}</span>,
},
{
title: "Connected",
key: "connected",
render: (_, cred) => <span className="text-sm text-gray-500">{relativeTime(cred.connected_at) || "—"}</span>,
},
{
title: "Status",
key: "status",
render: (_, cred) => {
const exp = expiryLabel(cred.expires_at);
return <Tag color={exp === "Expired" ? "red" : "green"}>{exp}</Tag>;
},
},
{
title: "Actions",
key: "actions",
align: "right",
render: (_, cred) => {
const isRevoking = revoking.has(cred.server_id);
return (
<button
onClick={() => handleRevoke(cred.server_id)}
disabled={isRevoking}
title="Revoke connection"
className={`inline-flex items-center justify-center rounded-md border border-gray-200 px-2 py-1 text-gray-400 hover:text-red-500 hover:border-red-200 transition-colors ${isRevoking ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
style={{ background: "none" }}
>
{isRevoking ? <Spin size="small" /> : <DeleteOutlined className="text-sm" />}
</button>
);
},
},
];
return (
<div className="w-full">
{/* Header */}
<div className="mb-4">
<h2 className="text-base font-semibold text-gray-900 mb-0.5">App Credentials</h2>
<p className="text-sm text-gray-500 m-0">Your stored OAuth connections — used automatically in chat.</p>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Spin />
</div>
) : credentials.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-12 border border-dashed border-gray-200 rounded-lg">
<LinkOutlined className="text-2xl mb-3 block text-gray-300" />
No connections yet.
<br />
Go to <strong>Apps</strong> and click <strong>Connect</strong> to authorize an MCP server.
</div>
) : (
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table columns={columns} dataSource={credentials} rowKey="server_id" pagination={false} size="small" />
</div>
)}
</div>
);
};
export default MCPCredentialsTab;

View file

@ -0,0 +1,25 @@
import type { MCPEvent } from "../mcp_tools/types";
export type { MCPEvent };
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
reasoningContent?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;
timestamp: number;
}
export interface Conversation {
id: string;
title: string;
model: string;
messages: ChatMessage[];
mcpServerNames: string[];
createdAt: number;
updatedAt: number;
}

View file

@ -0,0 +1,40 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "vitest";
import { useChatHistory } from "./useChatHistory";
describe("useChatHistory", () => {
beforeEach(() => {
localStorage.clear();
});
it("does not leak conversations between different users on the same browser", () => {
const { result: alice } = renderHook(({ userId }) => useChatHistory(null, userId), {
initialProps: { userId: "alice" },
});
act(() => {
alice.current.createConversation("gpt-4");
});
expect(alice.current.conversations).toHaveLength(1);
const { result: bob } = renderHook(({ userId }) => useChatHistory(null, userId), {
initialProps: { userId: "bob" },
});
expect(bob.current.conversations).toHaveLength(0);
});
it("reloads conversations scoped to the new user when userId changes", () => {
const { result, rerender } = renderHook(({ userId }) => useChatHistory(null, userId), {
initialProps: { userId: "alice" },
});
act(() => {
result.current.createConversation("gpt-4");
});
expect(result.current.conversations).toHaveLength(1);
rerender({ userId: "bob" });
expect(result.current.conversations).toHaveLength(0);
rerender({ userId: "alice" });
expect(result.current.conversations).toHaveLength(1);
});
});

View file

@ -0,0 +1,221 @@
import { useCallback, useEffect, useState } from "react";
import { ChatMessage, Conversation } from "./types";
const STORAGE_KEY_PREFIX = "litellm_chat_history_v1";
const MAX_CONVERSATIONS = 100;
const TITLE_MAX_LENGTH = 40;
function generateTitle(firstUserMessage: string): string {
const trimmed = firstUserMessage.trim();
if (trimmed.length <= TITLE_MAX_LENGTH) {
return trimmed;
}
return trimmed.slice(0, TITLE_MAX_LENGTH) + "…";
}
function storageKeyFor(userId: string): string {
return `${STORAGE_KEY_PREFIX}:${encodeURIComponent(userId)}`;
}
function loadFromStorage(storageKey: string): { conversations: Conversation[]; storageUnavailable: boolean } {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return { conversations: [], storageUnavailable: false };
}
const parsed = JSON.parse(raw) as Conversation[];
return { conversations: parsed, storageUnavailable: false };
} catch {
return { conversations: [], storageUnavailable: true };
}
}
function saveToStorage(storageKey: string, conversations: Conversation[]): boolean {
try {
localStorage.setItem(storageKey, JSON.stringify(conversations));
return true;
} catch {
return false;
}
}
function trimConversations(conversations: Conversation[]): Conversation[] {
if (conversations.length <= MAX_CONVERSATIONS) {
return conversations;
}
return [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS);
}
export function useChatHistory(
activeConversationId: string | null,
userId: string,
): {
conversations: Conversation[];
activeConversation: Conversation | null;
storageUnavailable: boolean;
staleId: boolean;
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (
conversationId: string,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>,
) => void;
/** Remove the message with `messageId` and all subsequent messages from the conversation. */
truncateFromMessage: (conversationId: string, messageId: string) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, newTitle: string) => void;
setActiveConversationId: (id: string | null) => void;
} {
const [conversations, setConversations] = useState<Conversation[]>(
() => loadFromStorage(storageKeyFor(userId)).conversations,
);
const [storageUnavailable, setStorageUnavailable] = useState<boolean>(
() => loadFromStorage(storageKeyFor(userId)).storageUnavailable,
);
const [staleId, setStaleId] = useState(false);
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
// Sync internal active id whenever the URL-derived prop changes (e.g. "New chat" → null)
const [prevActiveConversationId, setPrevActiveConversationId] = useState(activeConversationId);
if (activeConversationId !== prevActiveConversationId) {
setPrevActiveConversationId(activeConversationId);
setCurrentActiveId(activeConversationId);
setStaleId(false);
}
// Reload conversations from storage whenever userId changes (e.g. auth resolving after mount)
const [prevUserId, setPrevUserId] = useState(userId);
if (userId !== prevUserId) {
setPrevUserId(userId);
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage(storageKeyFor(userId));
setConversations(loaded);
setStorageUnavailable(unavailable);
if (activeConversationId !== null && !loaded.some((c) => c.id === activeConversationId)) {
setStaleId(true);
}
}
// Persist to localStorage after every conversations change
useEffect(() => {
if (storageUnavailable) return;
const success = saveToStorage(storageKeyFor(userId), conversations);
if (!success) {
// Defer: a setState call directly in an effect body causes a synchronous
// cascading render; queuing it as a microtask callback avoids that.
queueMicrotask(() => setStorageUnavailable(true));
}
}, [conversations, userId, storageUnavailable]);
const createConversation = useCallback((model: string): string => {
const id = crypto.randomUUID();
const now = Date.now();
const newConversation: Conversation = {
id,
title: "New conversation",
model,
messages: [],
mcpServerNames: [],
createdAt: now,
updatedAt: now,
};
setConversations((prev) => trimConversations([newConversation, ...prev]));
setCurrentActiveId(id);
return id;
}, []);
const appendMessage = useCallback((conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
const newMessage: ChatMessage = {
...message,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const updatedMessages = [...conv.messages, newMessage];
let title = conv.title;
if (
title === "New conversation" &&
newMessage.role === "user" &&
conv.messages.filter((m) => m.role === "user").length === 0
) {
title = generateTitle(newMessage.content);
}
return { ...conv, title, messages: updatedMessages, updatedAt: Date.now() };
});
return trimConversations(updated);
});
}, []);
const updateLastAssistantMessage = useCallback(
(conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const messages = [...conv.messages];
const lastAssistantIndex = messages.reduceRight((found, msg, idx) => {
if (found !== -1) return found;
return msg.role === "assistant" ? idx : -1;
}, -1);
if (lastAssistantIndex === -1) return conv;
messages[lastAssistantIndex] = { ...messages[lastAssistantIndex], ...updates };
return { ...conv, messages, updatedAt: Date.now() };
});
return trimConversations(updated);
});
},
[],
);
const truncateFromMessage = useCallback((conversationId: string, messageId: string) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const idx = conv.messages.findIndex((m) => m.id === messageId);
if (idx === -1) return conv;
return { ...conv, messages: conv.messages.slice(0, idx), updatedAt: Date.now() };
});
return trimConversations(updated);
});
}, []);
const deleteConversation = useCallback(
(id: string) => {
setConversations((prev) => trimConversations(prev.filter((c) => c.id !== id)));
if (currentActiveId === id) setCurrentActiveId(null);
},
[currentActiveId],
);
const renameConversation = useCallback((id: string, newTitle: string) => {
setConversations((prev) =>
trimConversations(
prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle, updatedAt: Date.now() } : conv)),
),
);
}, []);
const setActiveConversationId = useCallback((id: string | null) => {
setCurrentActiveId(id);
setStaleId(false);
}, []);
const activeConversation =
currentActiveId !== null ? conversations.find((c) => c.id === currentActiveId) ?? null : null;
return {
conversations,
activeConversation,
storageUnavailable,
staleId,
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
setActiveConversationId,
};
}

View file

@ -95,6 +95,16 @@ describe("Sidebar (leftnav)", () => {
});
});
it("hides Chat by default", () => {
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.queryByText("Chat")).not.toBeInTheDocument();
});
it("shows Chat when enableChatUI is true", () => {
renderWithProviders(<Sidebar {...defaultProps} enableChatUI />);
expect(screen.getByText("Chat")).toBeInTheDocument();
});
it("expands a nested tab to reveal its children (Tools > Search Tools)", async () => {
renderWithProviders(<Sidebar {...defaultProps} />);

View file

@ -11,6 +11,7 @@ import {
BgColorsOutlined,
BlockOutlined,
BookOutlined,
CommentOutlined,
CreditCardOutlined,
DatabaseOutlined,
ExperimentOutlined,
@ -53,6 +54,7 @@ interface SidebarProps {
collapsed?: boolean;
enabledPagesInternalUsers?: string[] | null;
enableProjectsUI?: boolean;
enableChatUI?: boolean;
disableAgentsForInternalUsers?: boolean;
allowAgentsForTeamAdmins?: boolean;
disableVectorStoresForInternalUsers?: boolean;
@ -95,6 +97,16 @@ const menuGroups: MenuGroup[] = [
icon: <PlayCircleOutlined />,
roles: rolesWithWriteAccess,
},
{
key: "chat",
page: "chat",
label: (
<span className="flex items-center gap-2">
Chat <NewBadge />
</span>
),
icon: <CommentOutlined />,
},
{
key: "models",
page: "models",
@ -398,6 +410,7 @@ const Sidebar: React.FC<SidebarProps> = ({
collapsed = false,
enabledPagesInternalUsers,
enableProjectsUI,
enableChatUI,
disableAgentsForInternalUsers,
allowAgentsForTeamAdmins,
disableVectorStoresForInternalUsers,
@ -493,6 +506,9 @@ const Sidebar: React.FC<SidebarProps> = ({
// Hide Projects page if enableProjectsUI is not enabled
if (item.key === "projects" && !enableProjectsUI) return false;
// Hide Chat page if enableChatUI is not enabled
if (item.key === "chat" && !enableChatUI) return false;
// Hide agents and vector-stores pages for non-admin users when disabled,
// unless allow_*_for_team_admins is on and the user is a team admin.
if (

View file

@ -7,6 +7,7 @@
export const pageDescriptions: Record<string, string> = {
"api-keys": "Manage virtual keys for API access and authentication",
"llm-playground": "Interactive playground for testing LLM requests",
chat: "Chat with an LLM and connect your own MCP server credentials via OAuth",
models: "Configure and manage LLM models and endpoints",
agents: "Create and manage AI agents",
agentic: "Manage agentic resources: agents, workflow runs, and memory",

View file

@ -16,6 +16,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
"api-reference": "api-reference",
"llm-playground": "playground",
projects: "projects",
chat: "chat",
"access-groups": "access-groups",
budgets: "budgets",
workflows: "workflows",