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:
Saoud Rizwan
2026-06-17 21:55:31 -07:00
committed by GitHub
parent a51b156383
commit a3cd39da14
42 changed files with 4372 additions and 2551 deletions
+24
View File
@@ -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",
+27 -7
View File
@@ -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 {
+6
View File
@@ -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),
+9
View File
@@ -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,
+56 -4
View File
@@ -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");
+26 -1
View File
@@ -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);
});
});
+32 -2
View File
@@ -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>
+74 -1
View File
@@ -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(
{
+151 -3
View File
@@ -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");
});
});
+52 -2
View File
@@ -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),
});
+1
View File
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
providerId?: string;
model?: string;
workspaceRoot?: string;
createdAt?: number;
updatedAt?: number;
inputTokens?: number;
outputTokens?: number;
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 &middot; {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>
);
}
+91 -18
View File
@@ -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);
}
+4 -12
View File
@@ -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"],
+4 -2
View File
@@ -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=="],