mirror of
https://github.com/cline/cline.git
synced 2026-10-30 18:12:49 +08:00
feat(cli): update hub dashboard design with customizations break out (#11631)
* feat(cli): refresh hub dashboard navigation * fix(cli): address hub dashboard review feedback * feat(cli): polish hub dashboard mockup styling * fix(cli): polish hub dashboard pages * fix(cli): brand hub browser metadata * fix(cli): simplify marketplace entry details * fix(cli): consolidate customization catalog lists * fix(cli): dedupe marketplace installed rows * fix(cli): preserve marketplace controls in consolidated rows * fix(cli): align marketplace plugin controls * fix(cli): flatten marketplace plugin tool layout * fix(cli): place marketplace path below tags * fix(cli): show primitive icons in marketplace rows * fix(cli): sort sessions by displayed created time * fix(cli): restore hub account navigation * fix(cli): refine hub account navigation * fix(cli): reuse hub account credentials * fix(cli): support uninstalling local primitives
This commit is contained in:
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) {
|
||||
return () => {};
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
process.env[name] = value;
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue(
|
||||
"WORKSPACE_ROOT",
|
||||
options.cwd ? resolve(options.cwd) : undefined,
|
||||
),
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
|
||||
@@ -929,6 +929,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"dashboard",
|
||||
"--config",
|
||||
"/tmp/cline-config",
|
||||
"--data-dir",
|
||||
".cline-dashboard-data",
|
||||
"--port",
|
||||
"9090",
|
||||
"--no-open",
|
||||
@@ -939,6 +943,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configDir: "/tmp/cline-config",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
port: "9090",
|
||||
openBrowser: false,
|
||||
io: expect.any(Object),
|
||||
|
||||
@@ -595,7 +595,12 @@ export async function runCli(): Promise<void> {
|
||||
const dashboardCmd = program
|
||||
.command("dashboard")
|
||||
.description("Start the Cline Hub dashboard and open it in a browser")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Workspace root", process.cwd())
|
||||
.option(
|
||||
"--data-dir <dir>",
|
||||
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
|
||||
)
|
||||
.option("--host <host>", "Dashboard bind host")
|
||||
.option("--port <port>", "Dashboard HTTP/WebSocket port")
|
||||
.option("--public-url <url>", "Public dashboard URL")
|
||||
@@ -603,7 +608,9 @@ export async function runCli(): Promise<void> {
|
||||
.option("--no-open", "Start the dashboard without opening a browser")
|
||||
.action(async () => {
|
||||
const opts = dashboardCmd.opts<{
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -612,7 +619,9 @@ export async function runCli(): Promise<void> {
|
||||
}>();
|
||||
const { runDashboardCommand } = await import("./commands/dashboard");
|
||||
ctx.exitCode = await runDashboardCommand({
|
||||
configDir: opts.config,
|
||||
cwd: opts.cwd,
|
||||
dataDir: opts.dataDir,
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
publicUrl: opts.publicUrl,
|
||||
|
||||
@@ -4,15 +4,20 @@ import {
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
formatProviderOAuthApiKey,
|
||||
getLocalProviderModels,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -30,6 +35,7 @@ import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
@@ -57,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
|
||||
"delete_routine_schedule",
|
||||
]);
|
||||
|
||||
async function resolveHubClineAccountAuthToken(input: {
|
||||
settings?: ProviderSettings;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const credentials = input.settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
|
||||
: null;
|
||||
if (!credentials || !input.settings) {
|
||||
return getPersistedProviderApiKey("cline", input.settings);
|
||||
}
|
||||
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
"cline",
|
||||
input.settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
|
||||
return formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
}
|
||||
|
||||
export async function handleDesktopCommand(
|
||||
ctx: HubContext,
|
||||
command: string,
|
||||
@@ -133,10 +172,18 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
const apiBaseUrl =
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const authToken = await resolveHubClineAccountAuthToken({
|
||||
settings,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -234,6 +281,11 @@ export async function handleDesktopCommand(
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
|
||||
@@ -3,11 +3,27 @@ import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
|
||||
|
||||
describe("isWebviewRoute", () => {
|
||||
it.each([
|
||||
"/",
|
||||
"/chat",
|
||||
"/sessions",
|
||||
"/models",
|
||||
"/customizations",
|
||||
"/rules",
|
||||
"/hooks",
|
||||
"/mcp",
|
||||
"/plugins",
|
||||
"/skills",
|
||||
"/agents",
|
||||
"/tools",
|
||||
"/marketplace",
|
||||
"/marketplace/mcp",
|
||||
"/marketplace/skills",
|
||||
"/marketplace/plugins",
|
||||
])("matches marketplace SPA route %s", (pathname) => {
|
||||
"/channels",
|
||||
"/schedules",
|
||||
"/settings",
|
||||
"/settings/providers",
|
||||
])("matches dashboard SPA route %s", (pathname) => {
|
||||
expect(isWebviewRoute(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -26,4 +42,13 @@ describe("normalizeWebviewIndexHtml", () => {
|
||||
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the persisted theme bootstrap once", () => {
|
||||
const normalized = normalizeWebviewIndexHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
);
|
||||
|
||||
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
|
||||
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,17 @@ const NO_STORE_HEADERS = {
|
||||
};
|
||||
|
||||
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
|
||||
(() => {
|
||||
try {
|
||||
const theme = window.localStorage.getItem("cline-hub-theme");
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
switch (extname(path)) {
|
||||
@@ -49,23 +60,42 @@ export function isWebviewRoute(pathname: string): boolean {
|
||||
pathname === "/" ||
|
||||
pathname === "/index.html" ||
|
||||
pathname === "/chat" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/models" ||
|
||||
pathname === "/customizations" ||
|
||||
pathname === "/rules" ||
|
||||
pathname === "/hooks" ||
|
||||
pathname === "/mcp" ||
|
||||
pathname === "/plugins" ||
|
||||
pathname === "/skills" ||
|
||||
pathname === "/agents" ||
|
||||
pathname === "/tools" ||
|
||||
pathname === "/marketplace" ||
|
||||
pathname === "/marketplace/mcp" ||
|
||||
pathname === "/marketplace/skills" ||
|
||||
pathname === "/marketplace/plugins" ||
|
||||
pathname === "/channels" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/settings" ||
|
||||
pathname.startsWith("/settings/")
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWebviewIndexHtml(html: string): string {
|
||||
return html.replaceAll('src="./', 'src="/').replaceAll('href="./', 'href="/');
|
||||
const normalized = html
|
||||
.replaceAll('src="./', 'src="/')
|
||||
.replaceAll('href="./', 'href="/');
|
||||
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
|
||||
}
|
||||
|
||||
function renderDevIndexHtml(devServerUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${THEME_BOOTSTRAP_SCRIPT}
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script type="module">
|
||||
@@ -76,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<script type="module" src="${devServerUrl}/@vite/client"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -9,6 +16,7 @@ import {
|
||||
installMarketplaceEntry,
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntry,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
@@ -645,6 +653,67 @@ describe("marketplace installer", () => {
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("uninstalls local MCP servers by name", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive({
|
||||
type: "mcp",
|
||||
id: "context7",
|
||||
name: "context7",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled context7.",
|
||||
});
|
||||
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
|
||||
});
|
||||
|
||||
it("uninstalls local skills by removing their configured skill directory", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive(
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
path: skillPath,
|
||||
},
|
||||
{ workspaceRoot },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Review.",
|
||||
});
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports official plugin marketplace entries installed from Cline home", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
@@ -676,6 +745,10 @@ describe("marketplace installer", () => {
|
||||
});
|
||||
|
||||
it("does not report plugin inventory substring matches as installed", () => {
|
||||
process.env.CLINE_DIR = mkdtempSync(
|
||||
join(tmpdir(), "cline-marketplace-test-"),
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import {
|
||||
deleteMcpServer,
|
||||
@@ -12,6 +31,7 @@ import {
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
|
||||
|
||||
type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
@@ -34,7 +54,7 @@ type MarketplaceInstallInput = {
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
type: LocalPrimitiveType;
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
details?: JsonRecord;
|
||||
@@ -170,6 +190,42 @@ function readInstallRequest(args?: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function readLocalUninstallInput(args?: Record<string, unknown>): {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
name?: string;
|
||||
path?: string;
|
||||
} {
|
||||
const type = typeof args?.type === "string" ? args.type.trim() : "";
|
||||
if (
|
||||
type !== "mcp" &&
|
||||
type !== "skill" &&
|
||||
type !== "workflow" &&
|
||||
type !== "plugin"
|
||||
) {
|
||||
throw new Error(
|
||||
"local uninstall type must be mcp, skill, workflow, or plugin",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
typeof args?.id === "string" && args.id.trim().length > 0
|
||||
? args.id.trim()
|
||||
: typeof args?.name === "string" && args.name.trim().length > 0
|
||||
? args.name.trim()
|
||||
: typeof args?.path === "string" && args.path.trim().length > 0
|
||||
? args.path.trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
throw new Error("local uninstall id, name, or path is required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: typeof args?.name === "string" ? args.name.trim() : undefined,
|
||||
path: typeof args?.path === "string" ? args.path.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallInputList(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput[] {
|
||||
@@ -375,6 +431,98 @@ function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUserInstructionRemovalTarget(input: {
|
||||
type: "skill" | "workflow";
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): string {
|
||||
const filePath = resolve(input.path);
|
||||
const searchPaths =
|
||||
input.type === "skill"
|
||||
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
|
||||
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
|
||||
const containingRoot = searchPaths.find((root) =>
|
||||
isInsidePath(filePath, root),
|
||||
);
|
||||
if (!containingRoot) {
|
||||
throw new Error(
|
||||
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
|
||||
);
|
||||
}
|
||||
const stats = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stats?.isFile()) {
|
||||
throw new Error(`${input.type} file does not exist: ${filePath}`);
|
||||
}
|
||||
if (input.type === "workflow") {
|
||||
return filePath;
|
||||
}
|
||||
const skillDir = dirname(filePath);
|
||||
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
|
||||
}
|
||||
|
||||
export async function uninstallLocalPrimitive(
|
||||
args?: Record<string, unknown>,
|
||||
options: { workspaceRoot?: string } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const input = readLocalUninstallInput(args);
|
||||
if (input.type === "mcp") {
|
||||
const name = input.name ?? input.id;
|
||||
const response = deleteMcpServer(name);
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${name}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (input.type === "plugin") {
|
||||
const result = await uninstallLocalPlugin({
|
||||
name: input.path ? undefined : (input.name ?? input.id),
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${result.name}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
};
|
||||
}
|
||||
if (input.type === "skill" || input.type === "workflow") {
|
||||
if (!input.path) {
|
||||
throw new Error(`${input.type} uninstall requires a path.`);
|
||||
}
|
||||
const target = resolveUserInstructionRemovalTarget({
|
||||
type: input.type,
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const stats = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stats) {
|
||||
throw new Error(`${input.type} target does not exist: ${target}`);
|
||||
}
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${input.name ?? basename(target)}.`,
|
||||
details: { path: target },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported local uninstall type: ${input.type}`);
|
||||
}
|
||||
|
||||
function hashSource(source: string): string {
|
||||
return createHash("sha256").update(source).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export function toWebviewSessionSummary(
|
||||
providerId: session.provider,
|
||||
model: session.model,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listUserInstructionConfigs } from "./user-instructions";
|
||||
|
||||
describe("listUserInstructionConfigs", () => {
|
||||
const tempRoots: string[] = [];
|
||||
const envSnapshot = {
|
||||
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
|
||||
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("uses the package name for package-backed plugin entries", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
|
||||
const packageDir = join(
|
||||
tempRoot,
|
||||
".cline",
|
||||
"plugins",
|
||||
"_installed",
|
||||
"git",
|
||||
"github.com",
|
||||
"demo",
|
||||
"package",
|
||||
);
|
||||
await mkdir(packageDir, { recursive: true });
|
||||
const pluginPath = join(packageDir, "index.ts");
|
||||
await writeFile(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cline-sdk-portable-agents",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(pluginPath, "export default {};\n");
|
||||
|
||||
const data = await listUserInstructionConfigs(tempRoot);
|
||||
const plugins = data.plugins as Array<{ name: string; path: string }>;
|
||||
const plugin = plugins.find((item) => item.path === pluginPath);
|
||||
|
||||
expect(plugin?.name).toBe("cline-sdk-portable-agents");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { extname, join, basename as pathBasename } from "node:path";
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
basename as pathBasename,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
@@ -17,6 +25,48 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
||||
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
||||
}
|
||||
|
||||
function readPackageName(packageJsonPath: string): string | undefined {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: unknown;
|
||||
};
|
||||
return typeof packageJson.name === "string" && packageJson.name.trim()
|
||||
? packageJson.name.trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(parentPath: string, childPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
if (packageName) {
|
||||
return packageName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return pathBasename(filePath, extname(filePath));
|
||||
}
|
||||
|
||||
export async function listUserInstructionConfigs(
|
||||
targetWorkspaceRoot: string,
|
||||
): Promise<JsonRecord> {
|
||||
@@ -115,7 +165,7 @@ export async function listUserInstructionConfigs(
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) continue;
|
||||
pluginsByPath.set(filePath, {
|
||||
name: pathBasename(filePath, extname(filePath)),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspaceRoot?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webview</title>
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 957 B |
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
"group/accordion-trigger relative flex flex-1 cursor-pointer items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -24,7 +24,10 @@ function ComboboxTrigger({
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
className={cn(
|
||||
"cursor-pointer data-disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -140,7 +143,7 @@ function ComboboxItem({
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -250,7 +253,7 @@ function ComboboxChip({
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
className="-ml-1 cursor-pointer opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
|
||||
@@ -152,7 +152,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
"group/command-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -90,7 +90,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -115,7 +115,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -164,7 +164,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -205,7 +205,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -56,7 +56,7 @@ function MenubarTrigger({
|
||||
<DropdownMenuTrigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
|
||||
"flex cursor-pointer items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -155,7 +155,7 @@ function MenubarRadioItem({
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -23,7 +23,7 @@ function NativeSelect({
|
||||
<select
|
||||
data-slot="native-select"
|
||||
data-size={size}
|
||||
className="h-full w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||
className="h-full w-full min-w-0 cursor-pointer appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||
{...props}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
|
||||
@@ -18,7 +18,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||
<RadioPrimitive.Root
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 cursor-pointer rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-fit cursor-pointer items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,7 +117,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex w-full cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -157,7 +157,7 @@ function SelectScrollUpButton({
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
"top-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -175,7 +175,7 @@ function SelectScrollDownButton({
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
"bottom-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ function Switch({
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
"peer group/switch relative inline-flex cursor-pointer shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,7 +58,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Plug,
|
||||
Puzzle,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
@@ -28,6 +26,7 @@ import {
|
||||
type MarketplacePrimitiveType,
|
||||
type MarketplaceTag,
|
||||
} from "@/lib/marketplace";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
|
||||
|
||||
type EntryActionState =
|
||||
| { status: "idle" }
|
||||
@@ -36,12 +35,10 @@ type EntryActionState =
|
||||
| {
|
||||
status: "installed";
|
||||
message: string;
|
||||
output?: string;
|
||||
}
|
||||
| {
|
||||
status: "uninstalled";
|
||||
message: string;
|
||||
output?: string;
|
||||
}
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
@@ -68,23 +65,23 @@ const primitivePageDetails = {
|
||||
title: "MCP Servers",
|
||||
description:
|
||||
"Install Model Context Protocol servers into this CLI environment.",
|
||||
emptyInstalled: "No MCP servers installed from this catalog.",
|
||||
emptyInstalled: "No MCP servers installed.",
|
||||
emptyCatalog: "No MCP servers match the current filters.",
|
||||
icon: Server,
|
||||
},
|
||||
skill: {
|
||||
title: "Skills",
|
||||
description: "Install skills globally for Cline.",
|
||||
emptyInstalled: "No skills installed from this catalog.",
|
||||
emptyInstalled: "No skills installed.",
|
||||
emptyCatalog: "No skills match the current filters.",
|
||||
icon: Wrench,
|
||||
icon: Zap,
|
||||
},
|
||||
plugin: {
|
||||
title: "Plugins",
|
||||
description: "Install plugins into this CLI environment.",
|
||||
emptyInstalled: "No plugins installed from this catalog.",
|
||||
emptyInstalled: "No plugins installed.",
|
||||
emptyCatalog: "No plugins match the current filters.",
|
||||
icon: Plug,
|
||||
icon: Puzzle,
|
||||
},
|
||||
} satisfies Record<
|
||||
MarketplacePrimitiveType,
|
||||
@@ -97,30 +94,49 @@ const primitivePageDetails = {
|
||||
}
|
||||
>;
|
||||
|
||||
const primitiveBadgeLabels: Record<MarketplacePrimitiveType, string> = {
|
||||
mcp: "MCP Server",
|
||||
skill: "Skill",
|
||||
plugin: "Plugin",
|
||||
};
|
||||
const primitiveCommands = {
|
||||
mcp: "cline mcp install",
|
||||
plugin: "cline plugin install",
|
||||
skill: "cline skill add",
|
||||
} satisfies Record<MarketplacePrimitiveType, string>;
|
||||
|
||||
function TypeBadge({ type }: { type: MarketplacePrimitiveType }) {
|
||||
const tone = {
|
||||
mcp: "border-purple-400/40 bg-purple-500/10 text-purple-700 dark:text-purple-300",
|
||||
skill:
|
||||
"border-emerald-400/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
plugin: "border-sky-400/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
}[type];
|
||||
return (
|
||||
<Badge variant="outline" className={`${tone} max-w-32`}>
|
||||
<span className="truncate">{primitiveBadgeLabels[type]}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
export type MarketplaceLocalInstalledItem = {
|
||||
key: string;
|
||||
matchValues: string[];
|
||||
render: () => ReactNode;
|
||||
renderMatchedBadges?: () => ReactNode;
|
||||
renderMatchedControls?: () => ReactNode;
|
||||
renderMatchedDetails?: () => ReactNode;
|
||||
renderMatchedMeta?: () => ReactNode;
|
||||
};
|
||||
|
||||
function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function entryMatchValues(entry: MarketplaceEntry): Set<string> {
|
||||
return new Set(
|
||||
[entry.id, entry.name, ...entry.install.args]
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function entryMatchesLocalItem(
|
||||
entry: MarketplaceEntry,
|
||||
item: MarketplaceLocalInstalledItem,
|
||||
): boolean {
|
||||
const entryValues = entryMatchValues(entry);
|
||||
return item.matchValues
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean)
|
||||
.some((value) => entryValues.has(value));
|
||||
}
|
||||
|
||||
function entrySearchText(
|
||||
entry: MarketplaceEntry,
|
||||
tagLabels: Map<string, string>,
|
||||
@@ -139,19 +155,16 @@ function entrySearchText(
|
||||
function actionMessage(
|
||||
state: EntryActionState | undefined,
|
||||
): string | undefined {
|
||||
if (state?.status === "installed" || state?.status === "uninstalled") {
|
||||
if (
|
||||
state?.status === "installed" ||
|
||||
state?.status === "uninstalled" ||
|
||||
state?.status === "failed"
|
||||
) {
|
||||
return state.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function actionOutput(state: EntryActionState | undefined): string | undefined {
|
||||
if (state?.status === "installed" || state?.status === "uninstalled") {
|
||||
return state.output;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function EntryDetails({
|
||||
actionState,
|
||||
entry,
|
||||
@@ -163,20 +176,18 @@ function EntryDetails({
|
||||
entry.install.env?.filter((env) => env.required !== false) ?? [];
|
||||
const optionalEnv =
|
||||
entry.install.env?.filter((env) => env.required === false) ?? [];
|
||||
const statusMessage = actionMessage(actionState);
|
||||
const output = actionOutput(actionState);
|
||||
const hasSetupDetails =
|
||||
requiredEnv.length > 0 ||
|
||||
optionalEnv.length > 0 ||
|
||||
Boolean(entry.install.notes) ||
|
||||
actionState?.status === "failed";
|
||||
|
||||
const copyCommand = async () => {
|
||||
await navigator.clipboard?.writeText(entry.install.command);
|
||||
};
|
||||
if (!hasSetupDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-3 border-t pt-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="grid gap-3 border-t pt-3" data-marketplace-entry-details>
|
||||
{requiredEnv.length > 0 || optionalEnv.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
@@ -227,47 +238,11 @@ function EntryDetails({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<details className="rounded-lg border bg-muted/30 p-3">
|
||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manual CLI command
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Use this only if you prefer installing from a terminal.
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-md border bg-background p-2 font-mono text-xs text-muted-foreground">
|
||||
<span style={CODE_FONT_STYLE}>{entry.install.command}</span>
|
||||
</code>
|
||||
<Button
|
||||
onClick={copyCommand}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{statusMessage ? (
|
||||
<p className="text-sm text-muted-foreground">{statusMessage}</p>
|
||||
) : null}
|
||||
{actionState?.status === "failed" ? (
|
||||
<div className="max-h-44 overflow-auto rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{actionState.message}
|
||||
</div>
|
||||
) : null}
|
||||
{output ? (
|
||||
<pre
|
||||
className="max-h-44 overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs text-muted-foreground"
|
||||
style={CODE_FONT_STYLE}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +256,8 @@ function MarketplaceEntryCard({
|
||||
onInstall,
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
matchedLocalItems = [],
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
}: {
|
||||
actionState: EntryActionState | undefined;
|
||||
@@ -291,12 +268,20 @@ function MarketplaceEntryCard({
|
||||
onInstall: (entry: MarketplaceEntry) => void;
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
matchedLocalItems?: MarketplaceLocalInstalledItem[];
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
}) {
|
||||
const EntryIcon = primitivePageDetails[entry.type].icon;
|
||||
const busy =
|
||||
actionState?.status === "installing" ||
|
||||
actionState?.status === "uninstalling";
|
||||
const setupNeeded = Boolean(entry.install.env?.length);
|
||||
const hasExpandableDetails =
|
||||
setupNeeded ||
|
||||
Boolean(entry.install.notes) ||
|
||||
actionState?.status === "failed";
|
||||
const inlineMessage = actionMessage(actionState);
|
||||
const handleActionClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (installed) {
|
||||
@@ -314,31 +299,62 @@ function MarketplaceEntryCard({
|
||||
: installed
|
||||
? "Uninstall"
|
||||
: "Install";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: The card contains a nested action button, so the wrapper cannot be a native button.
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={() => onToggleExpanded(entry)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleExpanded(entry);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{entry.name}
|
||||
</h2>
|
||||
<TypeBadge type={entry.type} />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{entry.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{sourceLabel ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{sourceLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
{matchedLocalItems.map((item) =>
|
||||
item.renderMatchedBadges ? (
|
||||
<span className="contents" key={`${item.key}:badges`}>
|
||||
{item.renderMatchedBadges()}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
{matchedLocalItems.map((item) =>
|
||||
item.renderMatchedControls ? (
|
||||
<span
|
||||
className="contents"
|
||||
data-marketplace-entry-interactive
|
||||
key={`${item.key}:controls`}
|
||||
>
|
||||
{item.renderMatchedControls()}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{matchedLocalItems.some((item) => item.renderMatchedMeta) ? (
|
||||
<div className="mt-1 grid gap-1">
|
||||
{matchedLocalItems.map((item) =>
|
||||
item.renderMatchedMeta ? (
|
||||
<div key={`${item.key}:meta`}>{item.renderMatchedMeta()}</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
|
||||
{entry.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.slice(0, 5).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
@@ -349,19 +365,30 @@ function MarketplaceEntryCard({
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
|
||||
<div className="grid gap-2" data-marketplace-entry-details>
|
||||
{matchedLocalItems.map((item) =>
|
||||
item.renderMatchedDetails ? (
|
||||
<div key={item.key}>{item.renderMatchedDetails()}</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-h-5 text-xs text-muted-foreground">
|
||||
{installed ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Installed
|
||||
</span>
|
||||
{inlineMessage ? (
|
||||
<output
|
||||
className={
|
||||
actionState?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{inlineMessage}
|
||||
</output>
|
||||
) : setupNeeded ? (
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
Requires setup after install
|
||||
@@ -380,9 +407,50 @@ function MarketplaceEntryCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
{expanded && hasExpandableDetails ? (
|
||||
<EntryDetails actionState={actionState} entry={entry} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: The card contains a nested action button, so the wrapper cannot be a native button.
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(
|
||||
"[data-marketplace-entry-details], [data-marketplace-entry-interactive]",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onToggleExpanded(entry);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleExpanded(entry);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -421,9 +489,12 @@ function MarketplaceSection({
|
||||
expandedEntryKey,
|
||||
installedEntryKeys,
|
||||
installedStatusReady,
|
||||
localOnlyInstalledItems = [],
|
||||
matchedLocalItemsByEntryKey,
|
||||
onInstall,
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
title,
|
||||
}: {
|
||||
@@ -433,20 +504,25 @@ function MarketplaceSection({
|
||||
expandedEntryKey: string | null;
|
||||
installedEntryKeys: Set<string>;
|
||||
installedStatusReady: boolean;
|
||||
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
|
||||
matchedLocalItemsByEntryKey?: Map<string, MarketplaceLocalInstalledItem[]>;
|
||||
onInstall: (entry: MarketplaceEntry) => void;
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
title: string;
|
||||
}) {
|
||||
const totalCount = entries.length + localOnlyInstalledItems.length;
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{entries.length}</span>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
{totalCount > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{localOnlyInstalledItems.map((item) => item.render())}
|
||||
{entries.map((entry) => {
|
||||
const key = entryKey(entry);
|
||||
return (
|
||||
@@ -460,6 +536,8 @@ function MarketplaceSection({
|
||||
onInstall={onInstall}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onUninstall={onUninstall}
|
||||
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
|
||||
sourceLabel={sourceLabel}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
);
|
||||
@@ -475,8 +553,14 @@ function MarketplaceSection({
|
||||
}
|
||||
|
||||
export function MarketplaceView({
|
||||
chrome = "page",
|
||||
installedItems,
|
||||
onInstalledItemsChanged,
|
||||
primitive,
|
||||
}: {
|
||||
chrome?: "page" | "embedded";
|
||||
installedItems?: MarketplaceLocalInstalledItem[];
|
||||
onInstalledItemsChanged?: () => void | Promise<void>;
|
||||
primitive: MarketplacePrimitiveType;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<MarketplaceCatalog | null>(null);
|
||||
@@ -573,6 +657,43 @@ export function MarketplaceView({
|
||||
});
|
||||
}, [primitiveEntries, query, selectedTag, tagLabels]);
|
||||
|
||||
const matchedLocalItemsByEntryKey = useMemo(() => {
|
||||
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
|
||||
for (const item of installedItems ?? []) {
|
||||
for (const entry of filteredEntries) {
|
||||
const key = entryKey(entry);
|
||||
if (
|
||||
!installedEntryKeys.has(key) ||
|
||||
!entryMatchesLocalItem(entry, item)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const items = matched.get(key) ?? [];
|
||||
items.push(item);
|
||||
matched.set(key, items);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [filteredEntries, installedEntryKeys, installedItems]);
|
||||
|
||||
const matchedLocalItemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
|
||||
items.map((item) => item.key),
|
||||
),
|
||||
),
|
||||
[matchedLocalItemsByEntryKey],
|
||||
);
|
||||
|
||||
const localOnlyInstalledItems = useMemo(
|
||||
() =>
|
||||
(installedItems ?? []).filter(
|
||||
(item) => !matchedLocalItemKeys.has(item.key),
|
||||
),
|
||||
[installedItems, matchedLocalItemKeys],
|
||||
);
|
||||
|
||||
const installedEntries = useMemo(
|
||||
() =>
|
||||
filteredEntries.filter((entry) =>
|
||||
@@ -643,9 +764,9 @@ export function MarketplaceView({
|
||||
setEntryState(entry, {
|
||||
status: "installed",
|
||||
message: result.message,
|
||||
output: result.output,
|
||||
});
|
||||
markEntryInstalled(entry);
|
||||
await onInstalledItemsChanged?.();
|
||||
} catch (error) {
|
||||
setEntryState(entry, {
|
||||
status: "failed",
|
||||
@@ -674,9 +795,9 @@ export function MarketplaceView({
|
||||
setEntryState(entry, {
|
||||
status: "uninstalled",
|
||||
message: result.message,
|
||||
output: result.output,
|
||||
});
|
||||
markEntryUninstalled(entry);
|
||||
await onInstalledItemsChanged?.();
|
||||
} catch (error) {
|
||||
setEntryState(entry, {
|
||||
status: "failed",
|
||||
@@ -685,134 +806,136 @@ export function MarketplaceView({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-6xl px-6 py-6 max-[720px]:px-3">
|
||||
<div className="mb-6 flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold tracking-normal text-foreground">
|
||||
<PageIcon className="size-8 text-primary" />
|
||||
<span>{pageDetails.title}</span>
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{pageDetails.description}
|
||||
</p>
|
||||
</div>
|
||||
{catalog?.generatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updated{" "}
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(catalog.generatedAt))}
|
||||
</p>
|
||||
) : null}
|
||||
const content = (
|
||||
<div className="grid gap-6">
|
||||
{chrome === "page" ? (
|
||||
<PageHeader
|
||||
description={pageDetails.description}
|
||||
icon={PageIcon}
|
||||
title={pageDetails.title}
|
||||
meta={<CommandBadge>{primitiveCommands[primitive]}</CommandBadge>}
|
||||
actions={
|
||||
catalog?.generatedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updated{" "}
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(catalog.generatedAt))}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!catalog && !errorMessage ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Loading marketplace...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!catalog && !errorMessage ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Loading marketplace...
|
||||
</div>
|
||||
) : null}
|
||||
{catalog && !installedStatusReady ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Checking installed status...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalog && !installedStatusReady ? (
|
||||
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Checking installed status...
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalog && installedStatusReady ? (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative block flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={`Search ${pageDetails.title}`}
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={`Search ${pageDetails.title.toLowerCase()}`}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{filteredEntries.length}
|
||||
</span>
|
||||
<span>
|
||||
{filteredEntries.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
{activeFilters ? (
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{catalog && installedStatusReady ? (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative block flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={`Search ${pageDetails.title}`}
|
||||
className="h-10 pl-8"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={`Search ${pageDetails.title.toLowerCase()}`}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{filteredEntries.length}
|
||||
</span>
|
||||
<span>
|
||||
{filteredEntries.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
{activeFilters ? (
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{primitiveTags.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyInstalled}
|
||||
entries={installedEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
/>
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title="Catalog"
|
||||
/>
|
||||
{primitiveTags.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyInstalled}
|
||||
entries={installedEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
localOnlyInstalledItems={localOnlyInstalledItems}
|
||||
matchedLocalItemsByEntryKey={matchedLocalItemsByEntryKey}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
/>
|
||||
|
||||
<MarketplaceSection
|
||||
actionStates={actionStates}
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
tagLabels={tagLabels}
|
||||
title="Marketplace"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return chrome === "embedded" ? content : <PageFrame>{content}</PageFrame>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageFrameProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function PageFrame({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
className,
|
||||
description,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
|
||||
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type PageEmptyStateProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandBadgeProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CommandBadge({ children, className }: CommandBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -15,15 +15,18 @@ import {
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Plus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
function normalizeAccountViewError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -35,6 +38,16 @@ function normalizeAccountViewError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
function isAccountAuthError(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes("no cline account auth token found") ||
|
||||
normalized.includes("requires re-authentication") ||
|
||||
normalized.includes("auth token") ||
|
||||
normalized.includes("unauthorized")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching helpers via sidecar command
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,6 +138,9 @@ export function AccountView() {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
|
||||
"overview",
|
||||
);
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
|
||||
// Overview data
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
@@ -155,6 +171,19 @@ export function AccountView() {
|
||||
const [billingLoaded, setBillingLoaded] = useState(false);
|
||||
const activeOrganization = organizations.find((org) => org.active) ?? null;
|
||||
|
||||
const resetAccountData = useCallback(() => {
|
||||
setUser(null);
|
||||
setBalance(null);
|
||||
setOrganizationBalance(null);
|
||||
setOrganizations([]);
|
||||
setUsageTransactions([]);
|
||||
setUsageLoaded(false);
|
||||
setUsageError(null);
|
||||
setPaymentTransactions([]);
|
||||
setBillingLoaded(false);
|
||||
setBillingError(null);
|
||||
}, []);
|
||||
|
||||
// -- Overview fetch --
|
||||
const loadOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
@@ -175,17 +204,61 @@ export function AccountView() {
|
||||
setOrganizationBalance(organizationBalanceData);
|
||||
setOrganizations(orgsData);
|
||||
} catch (err) {
|
||||
resetAccountData();
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [resetAccountData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
const signIn = async () => {
|
||||
setAccountActionPending("sign-in");
|
||||
setOverviewError(null);
|
||||
try {
|
||||
await desktopClient.invoke("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
await loadOverview();
|
||||
setActiveTab("overview");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
resetAccountData();
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
setAccountActionPending("sign-out");
|
||||
try {
|
||||
await desktopClient.invoke("save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: {
|
||||
auth: {
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
accountId: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
resetAccountData();
|
||||
setActiveTab("overview");
|
||||
setOverviewError("No Cline account auth token found");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Usage fetch (lazy on tab switch) --
|
||||
const loadUsage = useCallback(async () => {
|
||||
const generation = usageGenerationRef.current;
|
||||
@@ -295,6 +368,48 @@ export function AccountView() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSignedOut = () => (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<UserCircleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Sign in to Cline
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Connect your Cline account to review credits, usage, billing, and
|
||||
organization details from Cline Hub.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signIn()}
|
||||
type="button"
|
||||
>
|
||||
{accountActionPending === "sign-in" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
|
||||
</Button>
|
||||
<a
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
href="https://app.cline.bot"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -302,24 +417,19 @@ export function AccountView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Review account, usage, billing, and organization details."
|
||||
title="Account"
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -328,6 +438,8 @@ export function AccountView() {
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
@@ -335,243 +447,265 @@ export function AccountView() {
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError &&
|
||||
(isAccountAuthError(overviewError)
|
||||
? renderSignedOut()
|
||||
: renderError(overviewError, loadOverview))}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open dashboard
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<Button
|
||||
className="h-8 rounded-md px-2.5 text-xs"
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signOut()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{accountActionPending === "sign-out" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{accountActionPending === "sign-out"
|
||||
? "Signing out"
|
||||
: "Sign out"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/onboarding?step=1"
|
||||
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
Credit
|
||||
</a>
|
||||
</div>
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No organizations yet.
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={org.organizationId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{org.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{org.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{org.roles.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{org.active && (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading &&
|
||||
!usageError &&
|
||||
usageLoaded &&
|
||||
(usageTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Organizations */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/onboarding?step=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No organizations yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
key={org.organizationId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{org.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{org.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{org.roles.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{org.active && (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading &&
|
||||
!usageError &&
|
||||
usageLoaded &&
|
||||
(usageTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
const CAPABILITY_OPTIONS = [
|
||||
"streaming",
|
||||
@@ -204,9 +204,11 @@ export function AddProviderContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<PageFrame contentClassName="max-w-4xl">
|
||||
<PageHeader
|
||||
description="Add an OpenAI-compatible provider and choose its available models."
|
||||
title="Add Provider"
|
||||
actions={
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="secondary"
|
||||
@@ -214,320 +216,314 @@ export function AddProviderContent({
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Providers
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Add Provider
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0
|
||||
? "Type model ID and press Enter"
|
||||
: ""
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
Provider Name
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0 ? "Type model ID and press Enter" : ""
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateHeaderValue(key, e.target.value)
|
||||
}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,6 +34,12 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CommandBadge,
|
||||
PageEmptyState,
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
|
||||
type ConnectorField = {
|
||||
flag: string;
|
||||
@@ -341,16 +346,13 @@ export function ChannelsContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Channels</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeConnectors.length} connected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline Hub.`}
|
||||
title="Channels"
|
||||
meta={<CommandBadge>cline connect</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshChannels()}
|
||||
@@ -371,85 +373,81 @@ export function ChannelsContent() {
|
||||
<Plus className="size-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PageEmptyState>Loading channels...</PageEmptyState>
|
||||
) : activeConnectors.length === 0 ? (
|
||||
<PageEmptyState>No channels connected.</PageEmptyState>
|
||||
) : (
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid gap-2 p-2.5">
|
||||
{isLoading ? (
|
||||
<p className="px-1 py-4 text-[13px] text-muted-foreground">
|
||||
Loading channels...
|
||||
</p>
|
||||
) : activeConnectors.length === 0 ? (
|
||||
<p className="px-1 py-4 text-[13px] text-muted-foreground">
|
||||
No channels connected.
|
||||
</p>
|
||||
) : (
|
||||
activeConnectors.map((connector) => (
|
||||
<div
|
||||
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
key={connector.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
|
||||
<p className="truncate text-[13px] font-semibold leading-tight">
|
||||
{connectorName(connector, channels)}
|
||||
</p>
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{connectorIdentity(connector)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
pid={connector.pid}
|
||||
</span>
|
||||
{activeConnectors.map((connector) => (
|
||||
<div
|
||||
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
key={connector.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
|
||||
<p className="truncate text-[13px] font-semibold leading-tight">
|
||||
{connectorName(connector, channels)}
|
||||
</p>
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{connectorIdentity(connector)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
pid={connector.pid}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.hubUrl}
|
||||
>
|
||||
{connector.hubUrl}
|
||||
</span>
|
||||
{connector.baseUrl ? (
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.hubUrl}
|
||||
title={connector.baseUrl}
|
||||
>
|
||||
{connector.hubUrl}
|
||||
{connector.baseUrl}
|
||||
</span>
|
||||
{connector.baseUrl ? (
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.baseUrl}
|
||||
>
|
||||
{connector.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
) : null}
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
disabled={busyChannel === connector.type}
|
||||
onClick={() => setRemoveTarget(connector)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove...
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
disabled={busyChannel === connector.type}
|
||||
onClick={() => setRemoveTarget(connector)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove...
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
|
||||
@@ -639,6 +637,6 @@ export function ChannelsContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
@@ -405,18 +405,24 @@ export function McpServersContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-lg font-semibold text-foreground">
|
||||
MCP Servers
|
||||
</h2>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description={
|
||||
hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."
|
||||
}
|
||||
title="MCP Servers"
|
||||
meta={
|
||||
<>
|
||||
<CommandBadge>cline config mcp</CommandBadge>
|
||||
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -431,145 +437,133 @@ export function McpServersContent() {
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."}
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Command:
|
||||
</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers &&
|
||||
Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Headers:
|
||||
</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Command:</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers && Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Headers:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -854,6 +848,6 @@ export function McpServersContent() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -89,58 +90,142 @@ export function ProviderListContent({
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
selectedProviderId,
|
||||
variant = "page",
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
selectedProviderId?: string | null;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const enabledProviderCount = providers.filter(
|
||||
(provider) => provider.enabled,
|
||||
).length;
|
||||
const providerSearchQuery = providerSearch.trim().toLowerCase();
|
||||
const filteredProviders = providerSearchQuery
|
||||
? providers.filter((provider) =>
|
||||
provider.name.toLowerCase().includes(providerSearchQuery),
|
||||
)
|
||||
: providers;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Model Providers
|
||||
</h2>
|
||||
<Button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
|
||||
onClick={onAddProvider}
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Models
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
{providers.length} available · {enabledProviderCount}{" "}
|
||||
enabled
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
|
||||
<Button
|
||||
aria-label="Search providers"
|
||||
className="size-8 rounded-md"
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={providerSearchOpen ? "default" : "secondary"}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
|
||||
onClick={onAddProvider}
|
||||
type="button"
|
||||
>
|
||||
<PlusCircle className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
|
||||
{providers.map((prov) => (
|
||||
{providerSearchOpen ? (
|
||||
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
autoFocus
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
value={providerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
|
||||
selectedProviderId === prov.id && "bg-accent/45",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -158,6 +243,7 @@ export function ProviderDetailContent({
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
variant = "page",
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
@@ -167,6 +253,7 @@ export function ProviderDetailContent({
|
||||
modelsError?: string | null;
|
||||
onOAuthLogin?: () => void;
|
||||
oauthLoginPending?: boolean;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
@@ -199,6 +286,7 @@ export function ProviderDetailContent({
|
||||
model.id.toLowerCase().includes(modelSearchQuery),
|
||||
)
|
||||
: modelList;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -250,44 +338,65 @@ export function ProviderDetailContent({
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label="Back to providers"
|
||||
aria-label={
|
||||
isPanel ? "Close provider details" : "Back to providers"
|
||||
}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{isPanel ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
<section
|
||||
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{configFields.map((field) => {
|
||||
const value = localConfigValues[field.path];
|
||||
const valueText = fieldValueToString(value);
|
||||
const isSecret = field.type === "password" || field.secret;
|
||||
const isShown = shownSecrets[field.path] ?? false;
|
||||
return (
|
||||
<div key={field.path}>
|
||||
<header className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
<div
|
||||
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
|
||||
key={field.path}
|
||||
>
|
||||
<header>
|
||||
<h3 className="text-[17px] font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
@@ -300,7 +409,7 @@ export function ProviderDetailContent({
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
@@ -317,12 +426,12 @@ export function ProviderDetailContent({
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
|
||||
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground outline-none border-0"
|
||||
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
@@ -408,10 +517,18 @@ export function ProviderDetailContent({
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Models</h3>
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isPanel ? "max-w-none" : "max-w-[46rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
|
||||
<h2 className="text-[17px] font-medium text-muted-foreground">
|
||||
Models
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
aria-label="Refresh models"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
@@ -432,7 +549,7 @@ export function ProviderDetailContent({
|
||||
</div>
|
||||
) : modelList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-3 py-2">
|
||||
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search models"
|
||||
@@ -449,10 +566,10 @@ export function ProviderDetailContent({
|
||||
/>
|
||||
</div>
|
||||
{filteredModelList.length > 0 ? (
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
|
||||
<div className="max-h-125 overflow-y-scroll border-t">
|
||||
{filteredModelList.map((model) => (
|
||||
<div
|
||||
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -60,6 +59,12 @@ import {
|
||||
loadProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CommandBadge,
|
||||
PageEmptyState,
|
||||
PageFrame,
|
||||
PageHeader,
|
||||
} from "../page-layout";
|
||||
|
||||
type DateTimeValue = number | string;
|
||||
|
||||
@@ -871,18 +876,13 @@ export function RoutineSchedulesContent() {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-lg font-semibold text-foreground">
|
||||
Schedules
|
||||
</h2>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
cline schedule
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Scheduled jobs are run through the hub."
|
||||
title="Schedules"
|
||||
meta={<CommandBadge>cline schedule</CommandBadge>}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -897,223 +897,204 @@ export function RoutineSchedulesContent() {
|
||||
<Plus className="h-4 w-4" />
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
Scheduled jobs are run through the hub.
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading routines...
|
||||
</div>
|
||||
) : sortedSchedules.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No schedules found. Create a schedule to run routines on a recurring
|
||||
basis.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedSchedules.map((schedule) => {
|
||||
const isBusy = busyScheduleId === schedule.scheduleId;
|
||||
const activeExecution = executionBySchedule.get(
|
||||
schedule.scheduleId,
|
||||
);
|
||||
const lastExecution = lastExecutionBySchedule.get(
|
||||
schedule.scheduleId,
|
||||
);
|
||||
const upcoming = upcomingRuns.find(
|
||||
(item) => item.scheduleId === schedule.scheduleId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
{isLoading ? (
|
||||
<PageEmptyState>Loading schedules...</PageEmptyState>
|
||||
) : sortedSchedules.length === 0 ? (
|
||||
<PageEmptyState>
|
||||
No schedules found. Create a schedule to run routines on a recurring
|
||||
basis.
|
||||
</PageEmptyState>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedSchedules.map((schedule) => {
|
||||
const isBusy = busyScheduleId === schedule.scheduleId;
|
||||
const activeExecution = executionBySchedule.get(
|
||||
schedule.scheduleId,
|
||||
);
|
||||
const lastExecution = lastExecutionBySchedule.get(
|
||||
schedule.scheduleId,
|
||||
);
|
||||
const upcoming = upcomingRuns.find(
|
||||
(item) => item.scheduleId === schedule.scheduleId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
schedule.enabled
|
||||
? "fill-primary text-primary"
|
||||
: "fill-muted-foreground/40 text-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.mode}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.cronPattern}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => {
|
||||
window.alert(JSON.stringify(schedule, null, 2));
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() => void triggerSchedule(schedule.scheduleId)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? "fill-primary text-primary"
|
||||
: "fill-muted-foreground/40 text-muted-foreground/40",
|
||||
? `Pause ${schedule.name}`
|
||||
: `Resume ${schedule.name}`
|
||||
}
|
||||
onClick={() =>
|
||||
void upsertScheduleEnabled(schedule, !schedule.enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={schedule.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
void upsertScheduleEnabled(schedule, checked)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${schedule.name}`}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.mode}
|
||||
</span>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{schedule.cronPattern}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${schedule.name}`}
|
||||
onClick={() => {
|
||||
window.alert(JSON.stringify(schedule, null, 2));
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${schedule.name}`}
|
||||
onClick={() => openEditDialog(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Run ${schedule.name} now`}
|
||||
onClick={() =>
|
||||
void triggerSchedule(schedule.scheduleId)
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={
|
||||
schedule.enabled
|
||||
? `Pause ${schedule.name}`
|
||||
: `Resume ${schedule.name}`
|
||||
}
|
||||
onClick={() =>
|
||||
void upsertScheduleEnabled(
|
||||
schedule,
|
||||
!schedule.enabled,
|
||||
)
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{schedule.enabled ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${schedule.name}`}
|
||||
onClick={() => setSchedulePendingDelete(schedule)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={schedule.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
void upsertScheduleEnabled(schedule, checked)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${schedule.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">ID:</span>{" "}
|
||||
{schedule.scheduleId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Prompt:</span>{" "}
|
||||
{schedule.prompt}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(schedule)}
|
||||
</p>
|
||||
{schedule.workspaceRoot && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Workspace:
|
||||
</span>{" "}
|
||||
{schedule.workspaceRoot}
|
||||
</p>
|
||||
)}
|
||||
{schedule.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{schedule.cwd}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Last run:
|
||||
</span>{" "}
|
||||
{formatDateTime(schedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Last result:
|
||||
</span>{" "}
|
||||
{formatExecutionResult(lastExecution)}
|
||||
</p>
|
||||
{lastExecution?.sessionId && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Last session:
|
||||
</span>{" "}
|
||||
{lastExecution.sessionId}
|
||||
</p>
|
||||
)}
|
||||
{lastExecution?.errorMessage && (
|
||||
<p className="text-destructive">
|
||||
<span className="text-muted-foreground/70">
|
||||
Last error:
|
||||
</span>{" "}
|
||||
{lastExecution.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Next run:
|
||||
</span>{" "}
|
||||
{formatDateTime(
|
||||
schedule.nextRunAt || upcoming?.nextRunAt,
|
||||
)}
|
||||
</p>
|
||||
{activeExecution && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Active:
|
||||
</span>{" "}
|
||||
{activeExecution.executionId} since{" "}
|
||||
{formatDateTime(activeExecution.startedAt)}
|
||||
</p>
|
||||
)}
|
||||
{schedule.tags && schedule.tags.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Tags:</span>{" "}
|
||||
{schedule.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">ID:</span>{" "}
|
||||
{schedule.scheduleId}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Prompt:</span>{" "}
|
||||
{schedule.prompt}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Model:</span>{" "}
|
||||
{formatScheduleModel(schedule)}
|
||||
</p>
|
||||
{schedule.workspaceRoot && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Workspace:
|
||||
</span>{" "}
|
||||
{schedule.workspaceRoot}
|
||||
</p>
|
||||
)}
|
||||
{schedule.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{schedule.cwd}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Last run:</span>{" "}
|
||||
{formatDateTime(schedule.lastRunAt)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Last result:
|
||||
</span>{" "}
|
||||
{formatExecutionResult(lastExecution)}
|
||||
</p>
|
||||
{lastExecution?.sessionId && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Last session:
|
||||
</span>{" "}
|
||||
{lastExecution.sessionId}
|
||||
</p>
|
||||
)}
|
||||
{lastExecution?.errorMessage && (
|
||||
<p className="text-destructive">
|
||||
<span className="text-muted-foreground/70">
|
||||
Last error:
|
||||
</span>{" "}
|
||||
{lastExecution.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Next run:</span>{" "}
|
||||
{formatDateTime(schedule.nextRunAt || upcoming?.nextRunAt)}
|
||||
</p>
|
||||
{activeExecution && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Active:</span>{" "}
|
||||
{activeExecution.executionId} since{" "}
|
||||
{formatDateTime(activeExecution.startedAt)}
|
||||
</p>
|
||||
)}
|
||||
{schedule.tags && schedule.tags.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Tags:</span>{" "}
|
||||
{schedule.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={Boolean(schedulePendingDelete)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1554,6 +1535,6 @@ export function RoutineSchedulesContent() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollArea>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -12,11 +10,17 @@ import type {
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubTheme,
|
||||
readStoredHubTheme,
|
||||
readSystemHubTheme,
|
||||
setStoredHubTheme,
|
||||
} from "@/lib/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
@@ -32,7 +36,6 @@ import { toSettingsPatch } from "./settings-patch";
|
||||
const navCategories = [
|
||||
"General",
|
||||
"Providers",
|
||||
"Customizations",
|
||||
"MCP",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
@@ -40,7 +43,6 @@ const navCategories = [
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
type Theme = "dark" | "light";
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
@@ -58,17 +60,15 @@ let providerCatalogCache: {
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({
|
||||
chrome = "full",
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
onNavigateSection,
|
||||
onThemeChange,
|
||||
theme,
|
||||
}: {
|
||||
chrome?: "full" | "content";
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
theme: Theme;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const [providersExpanded, setProvidersExpanded] = useState(true);
|
||||
@@ -141,11 +141,14 @@ export function SettingsView({
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNav !== "Providers") {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderCatalog();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderCatalog]);
|
||||
}, [activeNav, loadProviderCatalog]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
@@ -349,6 +352,86 @@ export function SettingsView({
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const providerContent = addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading providers...</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedProvider ? (
|
||||
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
/>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
|
||||
provider={selectedProvider}
|
||||
variant="panel"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
);
|
||||
|
||||
const content =
|
||||
activeNav === "Providers" ? (
|
||||
providerContent
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
<GeneralSettingsContent />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (chrome === "content") {
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-background">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
@@ -440,88 +523,17 @@ export function SettingsView({
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeNav === "Providers" && selectedProvider ? (
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={
|
||||
modelsLoadingByProvider[selectedProvider.id] ?? false
|
||||
}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) =>
|
||||
updateProvider(selectedProvider.id, updates)
|
||||
}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
) : activeNav === "Providers" ? (
|
||||
addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
)
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Customizations" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
<GeneralSettingsContent
|
||||
onThemeChange={onThemeChange}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">{content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSettingsContent({
|
||||
onThemeChange,
|
||||
theme,
|
||||
}: {
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
theme: Theme;
|
||||
}) {
|
||||
function GeneralSettingsContent() {
|
||||
const [theme, setTheme] = useState<HubTheme>(() => {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return readStoredHubTheme() ?? readSystemHubTheme();
|
||||
});
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
@@ -603,85 +615,77 @@ function GeneralSettingsContent({
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
window.dispatchEvent(new Event("cline-hub-theme-change"));
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">General</h2>
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Manage Hub preferences for this browser and CLI environment."
|
||||
title="Settings"
|
||||
/>
|
||||
<section className="max-w-[86rem]">
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Dark mode
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Keep the Hub interface in dark mode on this browser.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Dark mode"
|
||||
checked={theme === "dark"}
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
<section className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Theme</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Use the light or dark Cline Hub interface.
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Auto update
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Automatically install CLI updates on startup.
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 max-[720px]:justify-start">
|
||||
<Button
|
||||
onClick={() => onThemeChange("dark")}
|
||||
type="button"
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
>
|
||||
<Moon className="size-4" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onThemeChange("light")}
|
||||
type="button"
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
Light
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Auto update</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically install CLI updates on startup.
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-foreground">
|
||||
Telemetry
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] text-muted-foreground">
|
||||
Enable error and usage report to help us improve Cline.
|
||||
</p>
|
||||
{telemetryError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update telemetry setting: {telemetryError}
|
||||
</p>
|
||||
{autoUpdateError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update auto update setting: {autoUpdateError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Auto update"
|
||||
checked={autoUpdateEnabled}
|
||||
disabled={autoUpdateLoading || autoUpdateSaving}
|
||||
onCheckedChange={(checked) =>
|
||||
void updateAutoUpdateEnabled(checked)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Telemetry</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Enable error and usage report to help us improve Cline.
|
||||
</p>
|
||||
{telemetryError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update telemetry setting: {telemetryError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Telemetry opt-out"
|
||||
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
|
||||
disabled={telemetryLoading || telemetrySaving}
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Switch
|
||||
aria-label="Telemetry"
|
||||
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
|
||||
disabled={telemetryLoading || telemetrySaving}
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.18 0.008 255);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.18 0.008 255);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.974 0.003 270);
|
||||
--muted-foreground: oklch(0.52 0.014 270);
|
||||
--accent: oklch(0.94 0.004 270);
|
||||
--accent-foreground: oklch(0.18 0.008 255);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.93 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--border: oklch(0.92 0.005 270);
|
||||
--input: oklch(0.9 0.006 270);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
@@ -31,18 +31,18 @@
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar: oklch(0.977 0.004 270);
|
||||
--sidebar-foreground: oklch(0.18 0.008 255);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-accent: oklch(0.928 0.006 270);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.008 255);
|
||||
--sidebar-border: oklch(0.92 0.005 270);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--font-sans: "Schibsted Grotesk Variable", sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
|
||||
monospace;
|
||||
@@ -93,13 +93,86 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
a[href],
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
summary,
|
||||
input[type="button"]:not(:disabled),
|
||||
input[type="checkbox"]:not(:disabled),
|
||||
input[type="radio"]:not(:disabled),
|
||||
input[type="reset"]:not(:disabled),
|
||||
input[type="submit"]:not(:disabled),
|
||||
label[for],
|
||||
select:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled,
|
||||
[aria-disabled="true"],
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
:is(
|
||||
[role="menuitem"],
|
||||
[role="menuitemcheckbox"],
|
||||
[role="menuitemradio"],
|
||||
[role="option"],
|
||||
[role="tab"],
|
||||
[role="switch"],
|
||||
[role="checkbox"],
|
||||
[role="radio"],
|
||||
[data-slot="switch"],
|
||||
[data-slot="checkbox"],
|
||||
[data-slot="radio-group-item"],
|
||||
[data-slot="tabs-trigger"],
|
||||
[data-slot="dropdown-menu-trigger"],
|
||||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-checkbox-item"],
|
||||
[data-slot="dropdown-menu-radio-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"],
|
||||
[data-slot="menubar-trigger"],
|
||||
[data-slot="menubar-item"],
|
||||
[data-slot="menubar-checkbox-item"],
|
||||
[data-slot="menubar-radio-item"],
|
||||
[data-slot="menubar-sub-trigger"],
|
||||
[data-slot="select-trigger"],
|
||||
[data-slot="select-item"],
|
||||
[data-slot="select-scroll-up-button"],
|
||||
[data-slot="select-scroll-down-button"],
|
||||
[data-slot="combobox-trigger"],
|
||||
[data-slot="combobox-clear"],
|
||||
[data-slot="combobox-item"],
|
||||
[data-slot="combobox-chip-remove"],
|
||||
[data-slot="command-item"],
|
||||
[data-slot="accordion-trigger"],
|
||||
[data-slot="collapsible-trigger"],
|
||||
[data-slot="dialog-trigger"],
|
||||
[data-slot="dialog-close"],
|
||||
[data-slot="alert-dialog-trigger"],
|
||||
[data-slot="alert-dialog-action"],
|
||||
[data-slot="alert-dialog-cancel"],
|
||||
[data-slot="popover-trigger"],
|
||||
[data-slot="hover-card-trigger"],
|
||||
[data-slot="tooltip-trigger"]
|
||||
):not(:disabled):not([aria-disabled="true"]):not([data-disabled]):not(
|
||||
[data-disabled="true"]
|
||||
) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-disabled],
|
||||
[data-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -110,12 +183,12 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.3 0.005 260);
|
||||
background: oklch(0.82 0.006 270);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.005 260);
|
||||
background: oklch(0.72 0.01 270);
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
|
||||
|
||||
export type HubTheme = "light" | "dark";
|
||||
|
||||
export function readStoredHubTheme(): HubTheme | null {
|
||||
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
|
||||
return stored === "light" || stored === "dark" ? stored : null;
|
||||
}
|
||||
|
||||
export function readSystemHubTheme(): HubTheme {
|
||||
const kind = document.body.dataset.vscodeThemeKind;
|
||||
return kind === "vscode-dark" || kind === "vscode-high-contrast"
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function applyHubTheme(theme: HubTheme): HubTheme {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function syncHubTheme(): HubTheme {
|
||||
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
|
||||
}
|
||||
|
||||
export function setStoredHubTheme(theme: HubTheme): HubTheme {
|
||||
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
|
||||
return applyHubTheme(theme);
|
||||
}
|
||||
@@ -3,23 +3,15 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import App from "./App.tsx";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
|
||||
/**
|
||||
* Sync the `dark` class on <html> with the VS Code theme.
|
||||
* VS Code sets `data-vscode-theme-kind` on <body> to one of:
|
||||
* "vscode-light" | "vscode-dark" | "vscode-high-contrast" | "vscode-high-contrast-light"
|
||||
*/
|
||||
function syncTheme() {
|
||||
const kind = document.body.dataset.vscodeThemeKind;
|
||||
const isDark = kind === "vscode-dark" || kind === "vscode-high-contrast";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
}
|
||||
window.addEventListener("cline-hub-theme-change", syncHubTheme);
|
||||
|
||||
// Apply immediately
|
||||
syncTheme();
|
||||
syncHubTheme();
|
||||
|
||||
// Re-apply whenever VS Code changes the theme attribute
|
||||
const observer = new MutationObserver(syncTheme);
|
||||
const observer = new MutationObserver(syncHubTheme);
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-vscode-theme-kind"],
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.25",
|
||||
"version": "3.0.27",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -75,7 +75,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
@@ -810,6 +810,8 @@
|
||||
|
||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.9", "", {}, "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ=="],
|
||||
|
||||
"@fontsource-variable/schibsted-grotesk": ["@fontsource-variable/schibsted-grotesk@5.2.8", "", {}, "sha512-nZAorDrFue4dXZbI613WdvAhu5DPH1UJYNn5fWl7Poa+nl/s9o3VUcQImIiVZpQnYG/jkPVzHsTE7WZbSDvvkw=="],
|
||||
|
||||
"@googleapis/chat": ["@googleapis/chat@44.7.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-1TTvtQ/nRD4Yb5PcMWl1J6vN3QjOyDr3t9XtM1iRJlAO8wux5dOChJSOyAlm2IOcRrrqmL4d0DVUkgMqovHjNA=="],
|
||||
|
||||
"@googleapis/workspaceevents": ["@googleapis/workspaceevents@9.2.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-9tU0h6O/Ao9k53TQSgLs6D/mN++wKTHPIpT8tbPaayhOL3e4OhtJ3t+OD42avMe2pBmFcdRUVOSpZvqytxvWEg=="],
|
||||
|
||||
Reference in New Issue
Block a user