From a3cd39da14cf8318b3778237e3ba82e7dfdc2ff2 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:55:31 -0700 Subject: [PATCH] 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 --- apps/cli/src/commands/dashboard.test.ts | 24 + apps/cli/src/commands/dashboard.ts | 34 +- apps/cli/src/main.test.ts | 6 + apps/cli/src/main.ts | 9 + apps/cline-hub/src/server/desktop-commands.ts | 60 +- apps/cline-hub/src/server/http.test.ts | 27 +- apps/cline-hub/src/server/http.ts | 34 +- apps/cline-hub/src/server/marketplace.test.ts | 75 +- apps/cline-hub/src/server/marketplace.ts | 154 +- apps/cline-hub/src/server/session-mapping.ts | 1 + .../src/server/user-instructions.test.ts | 70 + .../cline-hub/src/server/user-instructions.ts | 54 +- apps/cline-hub/src/webview-protocol.ts | 1 + apps/cline-hub/src/webview/index.html | 4 +- apps/cline-hub/src/webview/package.json | 2 +- .../src/webview/public/cline-logo-filled.svg | 5 + apps/cline-hub/src/webview/src/App.tsx | 1267 ++++++++----- .../webview/src/components/ui/accordion.tsx | 2 +- .../webview/src/components/ui/checkbox.tsx | 2 +- .../webview/src/components/ui/combobox.tsx | 9 +- .../src/webview/src/components/ui/command.tsx | 2 +- .../src/components/ui/dropdown-menu.tsx | 8 +- .../src/webview/src/components/ui/menubar.tsx | 6 +- .../src/components/ui/native-select.tsx | 2 +- .../webview/src/components/ui/radio-group.tsx | 2 +- .../src/webview/src/components/ui/select.tsx | 8 +- .../src/webview/src/components/ui/switch.tsx | 2 +- .../src/webview/src/components/ui/tabs.tsx | 2 +- .../src/components/views/marketplace-view.tsx | 607 +++--- .../src/components/views/page-layout.tsx | 111 ++ .../views/settings/account-view.tsx | 572 +++--- .../views/settings/add-provider.tsx | 568 +++--- .../views/settings/channels-view.tsx | 152 +- .../views/settings/extensions-view.tsx | 1622 +++++++++++------ .../components/views/settings/mcp-view.tsx | 282 ++- .../views/settings/provider-list-view.tsx | 219 ++- .../views/settings/routine-view.tsx | 429 +++-- .../views/settings/settings-view.tsx | 328 ++-- apps/cline-hub/src/webview/src/index.css | 109 +- apps/cline-hub/src/webview/src/lib/theme.ts | 30 + apps/cline-hub/src/webview/src/main.tsx | 16 +- bun.lock | 6 +- 42 files changed, 4372 insertions(+), 2551 deletions(-) create mode 100644 apps/cline-hub/src/server/user-instructions.test.ts create mode 100644 apps/cline-hub/src/webview/public/cline-logo-filled.svg create mode 100644 apps/cline-hub/src/webview/src/components/views/page-layout.tsx create mode 100644 apps/cline-hub/src/webview/src/lib/theme.ts diff --git a/apps/cli/src/commands/dashboard.test.ts b/apps/cli/src/commands/dashboard.test.ts index 6a3e72e9dc..387fc33bdf 100644 --- a/apps/cli/src/commands/dashboard.test.ts +++ b/apps/cli/src/commands/dashboard.test.ts @@ -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", diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index 0a104f91c3..c85ec5312c 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -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( options: RunDashboardCommandOptions, fn: () => Promise, ): Promise { + 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 { diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index 9b427fda78..871de9c99e 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -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), diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index d26b1965ab..940fc3e6df 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -595,7 +595,12 @@ export async function runCli(): Promise { const dashboardCmd = program .command("dashboard") .description("Start the Cline Hub dashboard and open it in a browser") + .option("--config ", "configuration directory") .option("-c, --cwd ", "Workspace root", process.cwd()) + .option( + "--data-dir ", + "Use isolated local state at instead of ~/.cline (enables sandbox mode)", + ) .option("--host ", "Dashboard bind host") .option("--port ", "Dashboard HTTP/WebSocket port") .option("--public-url ", "Public dashboard URL") @@ -603,7 +608,9 @@ export async function runCli(): Promise { .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 { }>(); 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, diff --git a/apps/cline-hub/src/server/desktop-commands.ts b/apps/cline-hub/src/server/desktop-commands.ts index 640f1276cb..5e2f703cf7 100644 --- a/apps/cline-hub/src/server/desktop-commands.ts +++ b/apps/cline-hub/src/server/desktop-commands.ts @@ -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 { + 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"); diff --git a/apps/cline-hub/src/server/http.test.ts b/apps/cline-hub/src/server/http.test.ts index b497ab398f..3aef7d5820 100644 --- a/apps/cline-hub/src/server/http.test.ts +++ b/apps/cline-hub/src/server/http.test.ts @@ -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", () => { '', ); }); + + it("injects the persisted theme bootstrap once", () => { + const normalized = normalizeWebviewIndexHtml( + "", + ); + + expect(normalized).toContain('id="cline-hub-theme-bootstrap"'); + expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized); + }); }); diff --git a/apps/cline-hub/src/server/http.ts b/apps/cline-hub/src/server/http.ts index 0d80b10a4d..d42565559e 100644 --- a/apps/cline-hub/src/server/http.ts +++ b/apps/cline-hub/src/server/http.ts @@ -22,6 +22,17 @@ const NO_STORE_HEADERS = { }; const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable"; +const THEME_BOOTSTRAP_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("", `\n${THEME_BOOTSTRAP_SCRIPT}`); } function renderDevIndexHtml(devServerUrl: string): string { return ` + ${THEME_BOOTSTRAP_SCRIPT} - + Cline Hub diff --git a/apps/cline-hub/src/server/marketplace.test.ts b/apps/cline-hub/src/server/marketplace.test.ts index 9263d56dd2..8668c81821 100644 --- a/apps/cline-hub/src/server/marketplace.test.ts +++ b/apps/cline-hub/src/server/marketplace.test.ts @@ -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( { diff --git a/apps/cline-hub/src/server/marketplace.ts b/apps/cline-hub/src/server/marketplace.ts index cfbc93c0f6..2dc419238f 100644 --- a/apps/cline-hub/src/server/marketplace.ts +++ b/apps/cline-hub/src/server/marketplace.ts @@ -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) { }; } +function readLocalUninstallInput(args?: Record): { + 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, ): 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, + options: { workspaceRoot?: string } = {}, +): Promise { + 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); } diff --git a/apps/cline-hub/src/server/session-mapping.ts b/apps/cline-hub/src/server/session-mapping.ts index baa57827c6..0aeb47c886 100644 --- a/apps/cline-hub/src/server/session-mapping.ts +++ b/apps/cline-hub/src/server/session-mapping.ts @@ -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, diff --git a/apps/cline-hub/src/server/user-instructions.test.ts b/apps/cline-hub/src/server/user-instructions.test.ts new file mode 100644 index 0000000000..2bdbd607cb --- /dev/null +++ b/apps/cline-hub/src/server/user-instructions.test.ts @@ -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"); + }); +}); diff --git a/apps/cline-hub/src/server/user-instructions.ts b/apps/cline-hub/src/server/user-instructions.ts index 184dc789f9..181704b062 100644 --- a/apps/cline-hub/src/server/user-instructions.ts +++ b/apps/cline-hub/src/server/user-instructions.ts @@ -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 { @@ -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), }); diff --git a/apps/cline-hub/src/webview-protocol.ts b/apps/cline-hub/src/webview-protocol.ts index 2961c66194..74955250a0 100644 --- a/apps/cline-hub/src/webview-protocol.ts +++ b/apps/cline-hub/src/webview-protocol.ts @@ -109,6 +109,7 @@ export type WebviewSessionSummary = { providerId?: string; model?: string; workspaceRoot?: string; + createdAt?: number; updatedAt?: number; inputTokens?: number; outputTokens?: number; diff --git a/apps/cline-hub/src/webview/index.html b/apps/cline-hub/src/webview/index.html index 2a7e2b7aba..ff7833d64f 100644 --- a/apps/cline-hub/src/webview/index.html +++ b/apps/cline-hub/src/webview/index.html @@ -2,9 +2,9 @@ - + - webview + Cline Hub
diff --git a/apps/cline-hub/src/webview/package.json b/apps/cline-hub/src/webview/package.json index 56e1f5e0a2..134d192ec4 100644 --- a/apps/cline-hub/src/webview/package.json +++ b/apps/cline-hub/src/webview/package.json @@ -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", diff --git a/apps/cline-hub/src/webview/public/cline-logo-filled.svg b/apps/cline-hub/src/webview/public/cline-logo-filled.svg new file mode 100644 index 0000000000..4e9df4e75d --- /dev/null +++ b/apps/cline-hub/src/webview/public/cline-logo-filled.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/cline-hub/src/webview/src/App.tsx b/apps/cline-hub/src/webview/src/App.tsx index 77b5e590b6..1e2c4f6b55 100644 --- a/apps/cline-hub/src/webview/src/App.tsx +++ b/apps/cline-hub/src/webview/src/App.tsx @@ -1,12 +1,16 @@ import { ActivityIcon, + ArrowUpDownIcon, BotIcon, - BoxIcon, ClockIcon, + CodeIcon, + FileTextIcon, + Folder, FunnelIcon, HomeIcon, LinkIcon, MessageSquareIcon, + MoreHorizontal, PencilIcon, PlugIcon, RotateCcwIcon, @@ -14,6 +18,7 @@ import { ServerIcon, SettingsIcon, Trash2Icon, + UserCircleIcon, WrenchIcon, } from "lucide-react"; import type { ReactNode } from "react"; @@ -49,24 +54,50 @@ import type { WebviewSessionSummary, } from "../../webview-protocol"; import Chat from "./Chat"; -import { MarketplaceView } from "./components/views/marketplace-view"; +import { PageFrame, PageHeader } from "./components/views/page-layout"; +import { + type CustomizationSection, + CustomizationSectionView, +} from "./components/views/settings/extensions-view"; import { type SettingsSection, SettingsView, } from "./components/views/settings/settings-view"; -import type { MarketplacePrimitiveType } from "./lib/marketplace"; -import { getVsCodeApi, postToHost } from "./vscode"; - -type View = "home" | "chat" | "mcp" | "skills" | "plugins" | "settings"; -type Theme = "dark" | "light"; +import { syncHubTheme } from "./lib/theme"; +import { postToHost } from "./vscode"; +type View = + | "home" + | "sessions" + | "chat" + | "models" + | "rules" + | "hooks" + | "mcp" + | "plugins" + | "skills" + | "agents" + | "tools" + | "channels" + | "schedules" + | "settings" + | "account"; const VIEW_PATHS: Record = { home: "/", + sessions: "/sessions", chat: "/chat", - mcp: "/marketplace/mcp", - skills: "/marketplace/skills", - plugins: "/marketplace/plugins", + models: "/models", + rules: "/rules", + hooks: "/hooks", + mcp: "/mcp", + plugins: "/plugins", + skills: "/skills", + agents: "/agents", + tools: "/tools", + channels: "/channels", + schedules: "/schedules", settings: "/settings", + account: "/settings/account", }; const CHAT_SESSION_QUERY_PARAM = "id"; @@ -74,18 +105,21 @@ const CHAT_SESSION_QUERY_PARAM = "id"; const SETTINGS_SECTION_PATHS: Record = { General: "/settings", Providers: "/settings/providers", - Customizations: "/settings/customizations", MCP: "/settings/mcp", Channels: "/settings/channels", Schedules: "/settings/schedules", Account: "/settings/account", }; -const MARKETPLACE_VIEW_PRIMITIVES = { - mcp: "mcp", - skills: "skill", - plugins: "plugin", -} satisfies Partial>; +const CUSTOMIZATION_VIEW_SECTIONS = { + rules: "Rules", + hooks: "Hooks", + mcp: "MCP", + skills: "Skills", + agents: "Agents", + plugins: "Plugins", + tools: "Tools", +} satisfies Partial>; const EMPTY_HUB_STATE: WebviewHubState = { type: "hub_state", @@ -98,30 +132,32 @@ const EMPTY_HUB_STATE: WebviewHubState = { events: [], }; -function readTheme(): Theme { - try { - const state = getVsCodeApi()?.getState() as { theme?: Theme } | undefined; - return state?.theme === "light" ? "light" : "dark"; - } catch { - return "dark"; - } -} - -function writeTheme(theme: Theme): void { - try { - const api = getVsCodeApi(); - const state = (api?.getState() as Record) ?? {}; - api?.setState({ ...state, theme }); - } catch { - // Theme persistence is best-effort. - } -} - function viewFromPath(pathname: string): View { + if (pathname === VIEW_PATHS.sessions) return "sessions"; if (pathname === VIEW_PATHS.chat) return "chat"; - if (pathname === "/marketplace" || pathname === VIEW_PATHS.mcp) return "mcp"; - if (pathname === VIEW_PATHS.skills) return "skills"; - if (pathname === VIEW_PATHS.plugins) return "plugins"; + if (pathname === VIEW_PATHS.models) return "models"; + if ( + pathname === VIEW_PATHS.rules || + pathname === "/customizations" || + pathname === "/settings/customizations" + ) + return "rules"; + if (pathname === VIEW_PATHS.hooks) return "hooks"; + if ( + pathname === "/marketplace" || + pathname === VIEW_PATHS.mcp || + pathname === "/marketplace/mcp" + ) + return "mcp"; + if (pathname === VIEW_PATHS.plugins || pathname === "/marketplace/plugins") + return "plugins"; + if (pathname === VIEW_PATHS.skills || pathname === "/marketplace/skills") + return "skills"; + if (pathname === VIEW_PATHS.agents) return "agents"; + if (pathname === VIEW_PATHS.tools) return "tools"; + if (pathname === VIEW_PATHS.channels) return "channels"; + if (pathname === VIEW_PATHS.schedules) return "schedules"; + if (pathname === VIEW_PATHS.account) return "account"; if ( pathname === VIEW_PATHS.settings || pathname.startsWith(`${VIEW_PATHS.settings}/`) @@ -183,6 +219,20 @@ function routePath(pathname: string): string { return query ? `${pathname}?${query}` : pathname; } +function replaceLegacyCustomizationRoute(): void { + if ( + typeof window === "undefined" || + (window.location.pathname !== "/customizations" && + window.location.pathname !== "/settings/customizations") + ) { + return; + } + const nextPath = routePath(VIEW_PATHS.rules); + if (currentPathWithSearch() !== nextPath) { + window.history.replaceState(null, "", nextPath); + } +} + function currentPathWithSearch(): string { if (typeof window === "undefined") return "/"; return `${window.location.pathname}${window.location.search}`; @@ -259,17 +309,11 @@ function connectorLabel(connector: WebviewActiveConnector): string { return shortId(connector.id); } -function sessionRunDetails(session: WebviewSessionSummary): string[] { - const inputTokens = formatCompactNumber(session.inputTokens); - const outputTokens = formatCompactNumber(session.outputTokens); - const cost = formatCost(session.totalCost); - return [ - workspaceName(session.workspaceRoot), - `${session.providerId}:${session.model}`, - inputTokens ? `${inputTokens}/${outputTokens}` : undefined, - cost, - session.source, - ].filter((detail): detail is string => Boolean(detail)); +function formatSessionModel(session: WebviewSessionSummary): string { + if (session.providerId && session.model) { + return `${session.providerId}:${session.model}`; + } + return session.model ?? session.providerId ?? "No model"; } function sessionFilterDetails(session: WebviewSessionSummary): string[] { @@ -286,59 +330,118 @@ function sessionFilterDetails(session: WebviewSessionSummary): string[] { function Shell({ children, onNavigate, + version, view, }: { children: ReactNode; onNavigate: (view: View) => void; - theme: Theme; + version?: string; view: View; }) { const navItems = [ { view: "home", label: "Home", icon: HomeIcon }, - { view: "chat", label: "Chat", icon: MessageSquareIcon }, - { view: "mcp", label: "MCP", icon: ServerIcon }, - { view: "skills", label: "Skills", icon: WrenchIcon }, - { view: "plugins", label: "Plugins", icon: PlugIcon }, + { view: "sessions", label: "Sessions", icon: MessageSquareIcon }, + { view: "models", label: "Models", icon: BotIcon }, + { view: "channels", label: "Channels", icon: LinkIcon }, + { view: "schedules", label: "Schedules", icon: ClockIcon }, + { view: "account", label: "Account", icon: UserCircleIcon }, { view: "settings", label: "Settings", icon: SettingsIcon }, ] satisfies Array<{ - view: View; + view: Exclude< + View, + | "chat" + | "rules" + | "hooks" + | "mcp" + | "plugins" + | "skills" + | "agents" + | "tools" + >; + label: string; + icon: typeof HomeIcon; + }>; + const customizationNavItems = [ + { view: "plugins", label: "Plugins", icon: PlugIcon }, + { view: "skills", label: "Skills", icon: ActivityIcon }, + { view: "mcp", label: "MCP", icon: ServerIcon }, + { view: "hooks", label: "Hooks", icon: CodeIcon }, + { view: "rules", label: "Rules", icon: FileTextIcon }, + { view: "agents", label: "Agents", icon: BotIcon }, + { view: "tools", label: "Tools", icon: WrenchIcon }, + ] satisfies Array<{ + view: Extract< + View, + "rules" | "hooks" | "mcp" | "plugins" | "skills" | "agents" | "tools" + >; label: string; icon: typeof HomeIcon; }>; + const renderNavButton = ( + item: (typeof navItems | typeof customizationNavItems)[number], + ) => { + const Icon = item.icon; + const active = + view === item.view || (item.view === "sessions" && view === "chat"); + return ( + + ); + }; + return ( -
-
- - ); -} - -function RecentSessionRow({ - onDelete, - onOpen, - onRename, - session, -}: { - onDelete: () => Promise | void; - onOpen: () => void; - onRename: (title: string) => Promise | void; - session: WebviewSessionSummary; -}) { - const runDetails = sessionRunDetails(session); - const currentSessionId = session.sessionId; - const currentTitle = session.title || shortId(session.sessionId); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleting, setDeleting] = useState(false); - const [editingTitle, setEditingTitle] = useState(false); - const [renaming, setRenaming] = useState(false); - const [titleDraft, setTitleDraft] = useState(currentTitle); - - const saveTitle = async () => { - if (renaming) return; - const nextTitle = titleDraft.replace(/\s+/g, " ").trim(); - setRenaming(true); - try { - await onRename(nextTitle); - setEditingTitle(false); - } finally { - setRenaming(false); - } - }; - - const confirmDelete = async () => { - setDeleting(true); - try { - await onDelete(); - setDeleteDialogOpen(false); - } finally { - setDeleting(false); - } - }; - - return ( -
-
- - {editingTitle ? ( -
{ - event.preventDefault(); - void saveTitle(); - }} - > - setTitleDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - setTitleDraft(currentTitle); - setEditingTitle(false); - } - }} - value={titleDraft} - /> - - -
- ) : ( -
- - - -
- )} -
- {runDetails.length > 0 ? ( -
- {formatRelativeTime(session.updatedAt)} - - {session.workspaceRoot} - - {runDetails.map((detail) => ( - - {detail} - - ))} +
+
+ Session title + Directory + Model + Tokens in + Tokens out + Cost + Created +
- ) : null} - +
+ {filteredSessions.length === 0 ? ( +
+ {sessions.length === 0 + ? "No sessions yet." + : "No sessions match the selected filters."} +
+ ) : null} + {filteredSessions.map((session) => { + const isEditing = editingSessionId === session.sessionId; + const title = session.title || shortId(session.sessionId); + return ( +
+ {isEditing ? ( +
{ + event.preventDefault(); + submitRenameSession(session); + }} + > +
+ + setEditingTitle(event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelRenameSession(); + } + }} + value={editingTitle} + /> + + +
+ + {formatSessionModel(session)} + + + {formatCompactNumber(session.inputTokens) ?? "-"} + + + {formatCompactNumber(session.outputTokens) ?? "-"} + + + {formatCost(session.totalCost) ?? "-"} + + + {formatRelativeTime( + session.createdAt ?? session.updatedAt, + )} + +
+ ) : ( + + )} + + event.stopPropagation()} + type="button" + /> + } + > + + + + { + event.stopPropagation(); + startRenameSession(session); + }} + > + + Rename + + + { + event.stopPropagation(); + setDeleteSessionCandidate(session); + }} + > + + Delete + + + +
+ ); + })} +
+
+ { + if (!open) { + setDeleteSessionCandidate(null); + } + }} + > - - Delete Session {currentSessionId} - + Delete session - This will permanently delete this session from Cline across - clients: -
- {currentTitle} + Delete{" "} + + {deleteSessionCandidate?.title || + (deleteSessionCandidate + ? shortId(deleteSessionCandidate.sessionId) + : "this session")} + + ? This removes it from recent sessions.
- Cancel + Cancel void confirmDelete()} + onClick={confirmDeleteSession} variant="destructive" > - {deleting ? "Deleting..." : "Delete"} + Delete
-
+ ); } function EventRow({ event }: { event: WebviewHubEvent }) { - const severityBorder = { - error: "border-l-destructive", - info: "border-l-blue-300", - success: "border-l-muted-foreground", - warn: "border-l-amber-300", - }[event.severity]; - return ( -
-
-

{event.title}

- {event.body} +
+
+ +
+

{event.title}

+

+ {event.body} +

+
-
@@ -896,7 +1097,6 @@ function App() { const [settingsSection, setSettingsSection] = useState(() => readCurrentSettingsSection(), ); - const [theme, setTheme] = useState(() => readTheme()); const [hubState, setHubState] = useState(EMPTY_HUB_STATE); const [restartPending, setRestartPending] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState< @@ -907,12 +1107,13 @@ function App() { ); useEffect(() => { - document.documentElement.classList.toggle("dark", theme === "dark"); - writeTheme(theme); - }, [theme]); + syncHubTheme(); + replaceLegacyCustomizationRoute(); + }, []); useEffect(() => { const handlePopState = () => { + replaceLegacyCustomizationRoute(); const nextView = readCurrentView(); setView(nextView); setSelectedSessionId( @@ -968,14 +1169,6 @@ function App() { setView(nextView); }, []); - const navigateSettingsSection = useCallback((section: SettingsSection) => { - setSettingsSection(section); - const nextPath = routePath(SETTINGS_SECTION_PATHS[section]); - if (currentPathWithSearch() !== nextPath) { - window.history.pushState(null, "", nextPath); - } - }, []); - const openSession = useCallback((sessionId: string) => { setSelectedSessionId(sessionId); const nextPath = chatPath(sessionId); @@ -1022,33 +1215,103 @@ function App() { /> ); } + if (view === "sessions") { + return ( + + ); + } if (view === "settings") { return ( navigate("home")} - onNavigateSection={navigateSettingsSection} - onThemeChange={setTheme} - theme={theme} /> ); } - if (view === "mcp" || view === "skills" || view === "plugins") { + if (view === "account") { return ( - navigate("home")} + /> + ); + } + if (view === "models") { + return ( + navigate("home")} + /> + ); + } + if (view === "channels") { + return ( + navigate("home")} + /> + ); + } + if (view === "schedules") { + return ( + navigate("home")} + /> + ); + } + if (view === "mcp") { + return ( + + ); + } + if (view === "skills" || view === "plugins") { + return ( + + ); + } + if ( + view === "rules" || + view === "hooks" || + view === "agents" || + view === "tools" + ) { + return ( + ); } return ( navigate("sessions")} recentSessions={recentSessions} restartPending={restartPending} /> @@ -1057,7 +1320,6 @@ function App() { hubState, deleteSession, navigate, - navigateSettingsSection, openSession, recentSessions, renameSession, @@ -1065,13 +1327,12 @@ function App() { restartPending, selectedSessionId, settingsSection, - theme, updateChatSessionRoute, view, ]); return ( - + {content} ); diff --git a/apps/cline-hub/src/webview/src/components/ui/accordion.tsx b/apps/cline-hub/src/webview/src/components/ui/accordion.tsx index 4f0489b668..82bfb9cbbc 100644 --- a/apps/cline-hub/src/webview/src/components/ui/accordion.tsx +++ b/apps/cline-hub/src/webview/src/components/ui/accordion.tsx @@ -32,7 +32,7 @@ function AccordionTrigger({ {children} @@ -140,7 +143,7 @@ function ComboboxItem({ } - className="-ml-1 opacity-50 hover:opacity-100" + className="-ml-1 cursor-pointer opacity-50 hover:opacity-100" data-slot="combobox-chip-remove" > diff --git a/apps/cline-hub/src/webview/src/components/ui/command.tsx b/apps/cline-hub/src/webview/src/components/ui/command.tsx index 65c3d22aa2..c8f9bc4492 100644 --- a/apps/cline-hub/src/webview/src/components/ui/command.tsx +++ b/apps/cline-hub/src/webview/src/components/ui/command.tsx @@ -152,7 +152,7 @@ function CommandItem({ ; -const primitiveBadgeLabels: Record = { - mcp: "MCP Server", - skill: "Skill", - plugin: "Plugin", -}; +const primitiveCommands = { + mcp: "cline mcp install", + plugin: "cline plugin install", + skill: "cline skill add", +} satisfies Record; -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 ( - - {primitiveBadgeLabels[type]} - - ); -} +export type MarketplaceLocalInstalledItem = { + key: string; + matchValues: string[]; + render: () => ReactNode; + renderMatchedBadges?: () => ReactNode; + renderMatchedControls?: () => ReactNode; + renderMatchedDetails?: () => ReactNode; + renderMatchedMeta?: () => ReactNode; +}; function entryKey(entry: Pick): string { return `${entry.type}:${entry.id}`; } +function normalizeMatchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function entryMatchValues(entry: MarketplaceEntry): Set { + 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, @@ -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 ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - role="presentation" - > +
{requiredEnv.length > 0 || optionalEnv.length > 0 ? (

@@ -227,47 +238,11 @@ function EntryDetails({

) : null} -
- - Manual CLI command - -
-

- Use this only if you prefer installing from a terminal. -

-
- - {entry.install.command} - - -
-
-
- - {statusMessage ? ( -

{statusMessage}

- ) : null} {actionState?.status === "failed" ? (
{actionState.message}
) : null} - {output ? ( -
-					{output}
-				
- ) : null}
); } @@ -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; }) { + 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) => { 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. -
onToggleExpanded(entry)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggleExpanded(entry); - } - }} - role="button" - tabIndex={0} - > + const content = ( + <>
-

- {entry.name} -

- +
+ +

+ {entry.name} +

+
+
+ {sourceLabel ? ( + + {sourceLabel} + + ) : null} + {matchedLocalItems.map((item) => + item.renderMatchedBadges ? ( + + {item.renderMatchedBadges()} + + ) : null, + )} + {matchedLocalItems.map((item) => + item.renderMatchedControls ? ( + + {item.renderMatchedControls()} + + ) : null, + )} +
-
+ {matchedLocalItems.some((item) => item.renderMatchedMeta) ? ( +
+ {matchedLocalItems.map((item) => + item.renderMatchedMeta ? ( +
{item.renderMatchedMeta()}
+ ) : null, + )} +
+ ) : null} +
+ +

+ {entry.description} +

+ + {entry.tags.length > 0 ? ( +
{entry.tags.slice(0, 5).map((tag) => ( ))}
-
+ ) : null} -

- {entry.description} -

+ {matchedLocalItems.some((item) => item.renderMatchedDetails) ? ( +
+ {matchedLocalItems.map((item) => + item.renderMatchedDetails ? ( +
{item.renderMatchedDetails()}
+ ) : null, + )} +
+ ) : null}
- {installed ? ( - - - Installed - + {inlineMessage ? ( + + {inlineMessage} + ) : setupNeeded ? ( Requires setup after install @@ -380,9 +407,50 @@ function MarketplaceEntryCard({
- {expanded ? ( + {expanded && hasExpandableDetails ? ( ) : null} + + ); + + if (!hasExpandableDetails) { + return ( +
+ {content} +
+ ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: The card contains a nested action button, so the wrapper cannot be a native button. +
{ + 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}
); } @@ -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; installedStatusReady: boolean; + localOnlyInstalledItems?: MarketplaceLocalInstalledItem[]; + matchedLocalItemsByEntryKey?: Map; onInstall: (entry: MarketplaceEntry) => void; onToggleExpanded: (entry: MarketplaceEntry) => void; onUninstall: (entry: MarketplaceEntry) => void; + sourceLabel?: string; tagLabels: Map; title: string; }) { + const totalCount = entries.length + localOnlyInstalledItems.length; return (

{title}

- {entries.length} + {totalCount}
- {entries.length > 0 ? ( + {totalCount > 0 ? (
+ {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; primitive: MarketplacePrimitiveType; }) { const [catalog, setCatalog] = useState(null); @@ -573,6 +657,43 @@ export function MarketplaceView({ }); }, [primitiveEntries, query, selectedTag, tagLabels]); + const matchedLocalItemsByEntryKey = useMemo(() => { + const matched = new Map(); + 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 ( - -
-
-
-

- - {pageDetails.title} -

-

- {pageDetails.description} -

-
- {catalog?.generatedAt ? ( -

- Updated{" "} - {new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(new Date(catalog.generatedAt))} -

- ) : null} + const content = ( +
+ {chrome === "page" ? ( + {primitiveCommands[primitive]}} + actions={ + catalog?.generatedAt ? ( +

+ Updated{" "} + {new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(catalog.generatedAt))} +

+ ) : null + } + /> + ) : null} + + {!catalog && !errorMessage ? ( +
+ + Loading marketplace...
+ ) : null} - {!catalog && !errorMessage ? ( -
- - Loading marketplace... -
- ) : null} + {catalog && !installedStatusReady ? ( +
+ + Checking installed status... +
+ ) : null} - {catalog && !installedStatusReady ? ( -
- - Checking installed status... -
- ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - - {catalog && installedStatusReady ? ( -
-
-
-
- - setQuery(event.target.value)} - placeholder={`Search ${pageDetails.title.toLowerCase()}`} - value={query} - /> -
-
- - {filteredEntries.length} - - - {filteredEntries.length === 1 ? "result" : "results"} - - {activeFilters ? ( - - ) : null} -
+ {catalog && installedStatusReady ? ( +
+
+
+
+ + setQuery(event.target.value)} + placeholder={`Search ${pageDetails.title.toLowerCase()}`} + value={query} + /> +
+
+ + {filteredEntries.length} + + + {filteredEntries.length === 1 ? "result" : "results"} + + {activeFilters ? ( + + ) : null}
- - {primitiveTags.length > 0 ? ( -
- {primitiveTags.map((tag) => ( - - setSelectedTag((current) => - current === tag.id ? null : tag.id, - ) - } - tag={tag} - /> - ))} -
- ) : null}
- - - + {primitiveTags.length > 0 ? ( +
+ {primitiveTags.map((tag) => ( + + setSelectedTag((current) => + current === tag.id ? null : tag.id, + ) + } + tag={tag} + /> + ))} +
+ ) : null}
- ) : null} -
- + + + + +
+ ) : null} +
); + + return chrome === "embedded" ? content : {content}; } diff --git a/apps/cline-hub/src/webview/src/components/views/page-layout.tsx b/apps/cline-hub/src/webview/src/components/views/page-layout.tsx new file mode 100644 index 0000000000..5d6497e929 --- /dev/null +++ b/apps/cline-hub/src/webview/src/components/views/page-layout.tsx @@ -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 ( + +
+
{children}
+
+
+ ); +} + +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 ( +
+
+
+ {Icon ? : null} +

+ {title} +

+ {meta} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ ); +} + +type PageEmptyStateProps = { + children: ReactNode; + className?: string; +}; + +export function PageEmptyState({ children, className }: PageEmptyStateProps) { + return ( +
+ {children} +
+ ); +} + +type CommandBadgeProps = { + children: ReactNode; + className?: string; +}; + +export function CommandBadge({ children, className }: CommandBadgeProps) { + return ( + + {children} + + ); +} diff --git a/apps/cline-hub/src/webview/src/components/views/settings/account-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/account-view.tsx index 49c2998435..3a106d4bfd 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/account-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/account-view.tsx @@ -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(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() {
); + const renderSignedOut = () => ( +
+
+
+ +
+
+

+ Sign in to Cline +

+

+ Connect your Cline account to review credits, usage, billing, and + organization details from Cline Hub. +

+
+
+ + + Create account + + +
+
+
+ ); + const renderLoading = () => (
@@ -302,24 +417,19 @@ export function AccountView() { ); return ( - -
- {/* Header */} -
-

Account

- -
+ + - {/* Tabs */} -
- {tabs.map((tab) => ( + {/* Tabs */} +
+ {tabs.map((tab) => { + const disabled = !user && tab !== "overview"; + return ( - ))} -
+ ); + })} +
- {/* Overview Tab */} - {activeTab === "overview" && ( -
- {overviewLoading && renderLoading()} - {overviewError && renderError(overviewError, loadOverview)} - {!overviewLoading && !overviewError && user && ( - <> - {/* User Profile Card */} -
-
-
- {user.displayName?.charAt(0) ?? - user.email?.charAt(0) ?? - "?"} -
-
-

- {user.displayName || user.email} -

-

- {user.email} -

-

- Member since {formatDate(user.createdAt)} -

-
+ {/* Overview Tab */} + {activeTab === "overview" && ( +
+ {overviewLoading && renderLoading()} + {overviewError && + (isAccountAuthError(overviewError) + ? renderSignedOut() + : renderError(overviewError, loadOverview))} + {!overviewLoading && !overviewError && user && ( + <> + {/* User Profile Card */} +
+
+
+ {user.displayName?.charAt(0) ?? + user.email?.charAt(0) ?? + "?"} +
+
+

+ {user.displayName || user.email} +

+

+ {user.email} +

+

+ Member since {formatDate(user.createdAt)} +

+
+
- + Open dashboard + +
+
- {/* Balance Card */} - {displayedBalance !== null && ( -
-
-
- -

- {activeOrganization - ? `${activeOrganization.name} Balance` - : "Credits Balance"} -

-
- - - Credit - -
-
- - ${formatCreditBalance(displayedBalance)} - -
- {activeOrganization && balance && ( -

- Personal account: {formatCreditBalance(balance.balance)}{" "} - credits -

- )} -
- )} - - {/* Organizations */} + {/* Balance Card */} + {displayedBalance !== null && (
- +

- Organizations + {activeOrganization + ? `${activeOrganization.name} Balance` + : "Credits Balance"}

- Create + Credit
- {organizations.length === 0 ? ( -

- No organizations yet. +

+ + ${formatCreditBalance(displayedBalance)} + +
+ {activeOrganization && balance && ( +

+ Personal account: {formatCreditBalance(balance.balance)}{" "} + credits

- ) : ( -
- {organizations.map((org) => ( -
-
- {org.name.charAt(0)} -
-
-

- {org.name} -

-

- {org.roles.join(", ")} -

-
- {org.active && ( - - Active - - )} -
- ))} -
)}
- - )} -
- )} + )} - {/* Usage Tab */} - {activeTab === "usage" && ( -
-

- {activeOrganization - ? `Recent API usage and token consumption for ${activeOrganization.name}.` - : "Recent API usage and token consumption across all providers."} -

- {usageLoading && renderLoading()} - {usageError && renderError(usageError, loadUsage)} - {!usageLoading && - !usageError && - usageLoaded && - (usageTransactions.length === 0 ? ( -

- No usage transactions yet. -

- ) : ( -
-
- Model - Tokens - Credits - Time -
-
- {usageTransactions.map((tx) => ( -
-
-

- {tx.aiModelName} -

-

- {tx.aiInferenceProviderName} -

-
-
- {tx.totalTokens.toLocaleString()} -
-
- {formatCreditBalance(tx.creditsUsed)} -
-
-

{formatDate(tx.createdAt)}

-

{formatTime(tx.createdAt)}

-
-
- ))} + {/* Organizations */} +
+
+
+ +

+ Organizations +

+ + + Create +
- ))} -
- )} - - {/* Billing Tab */} - {activeTab === "billing" && ( -
-

- Payment history and credit purchases. -

- {billingLoading && renderLoading()} - {billingError && renderError(billingError, loadBilling)} - {!billingLoading && - !billingError && - billingLoaded && - (paymentTransactions.length === 0 ? ( -

- No payment transactions yet. -

- ) : ( -
-
- Date - Amount - Credits -
-
- {paymentTransactions.map((tx) => ( + {organizations.length === 0 ? ( +

+ No organizations yet. +

+ ) : ( +
+ {organizations.map((org) => (
-
- - - {formatDate(tx.paidAt)} +
+ {org.name.charAt(0)} +
+
+

+ {org.name} +

+

+ {org.roles.join(", ")} +

+
+ {org.active && ( + + Active -
-
- ${(tx.amountCents / 100).toFixed(2)} -
-
- +{formatCreditBalance(tx.credits)} -
+ )}
))}
+ )} +
+ + )} +
+ )} + + {/* Usage Tab */} + {activeTab === "usage" && ( +
+

+ {activeOrganization + ? `Recent API usage and token consumption for ${activeOrganization.name}.` + : "Recent API usage and token consumption across all providers."} +

+ {usageLoading && renderLoading()} + {usageError && renderError(usageError, loadUsage)} + {!usageLoading && + !usageError && + usageLoaded && + (usageTransactions.length === 0 ? ( +

+ No usage transactions yet. +

+ ) : ( +
+
+ Model + Tokens + Credits + Time
- ))} -
- )} -
- +
+ {usageTransactions.map((tx) => ( +
+
+

+ {tx.aiModelName} +

+

+ {tx.aiInferenceProviderName} +

+
+
+ {tx.totalTokens.toLocaleString()} +
+
+ {formatCreditBalance(tx.creditsUsed)} +
+
+

{formatDate(tx.createdAt)}

+

{formatTime(tx.createdAt)}

+
+
+ ))} +
+
+ ))} +
+ )} + + {/* Billing Tab */} + {activeTab === "billing" && ( +
+

+ Payment history and credit purchases. +

+ {billingLoading && renderLoading()} + {billingError && renderError(billingError, loadBilling)} + {!billingLoading && + !billingError && + billingLoaded && + (paymentTransactions.length === 0 ? ( +

+ No payment transactions yet. +

+ ) : ( +
+
+ Date + Amount + Credits +
+
+ {paymentTransactions.map((tx) => ( +
+
+ + + {formatDate(tx.paidAt)} + +
+
+ ${(tx.amountCents / 100).toFixed(2)} +
+
+ +{formatCreditBalance(tx.credits)} +
+
+ ))} +
+
+ ))} +
+ )} + ); } diff --git a/apps/cline-hub/src/webview/src/components/views/settings/add-provider.tsx b/apps/cline-hub/src/webview/src/components/views/settings/add-provider.tsx index 14dd8b746a..83de774c0b 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/add-provider.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/add-provider.tsx @@ -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 ( - -
-
+ + + Providers -

- Add Provider -

-
+ } + /> -
-
-

- OpenAI-Compatible Provider -

-
-
- - - 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" - /> -

- Lowercase ID used in provider registry. -

- {duplicateProviderId ? ( -

- This provider ID already exists. -

- ) : null} -
-
- - - 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" - /> -
-
-
- -
- - - 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" - /> -
- -
- - - 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" - /> -

- Supported JSON: OpenAI `/models` shape with a `data` array, or a - direct model array. -

-
- -
- -
- {form.models.map((model) => ( - - {model} - - - ))} +
+
+

+ OpenAI-Compatible Provider +

+
+
+ 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" /> +

+ Lowercase ID used in provider registry. +

+ {duplicateProviderId ? ( +

+ This provider ID already exists. +

+ ) : null}
-

- Add at least one model or set a Model Source URL. -

-
- - {form.models.length > 1 ? ( -
+
- -
- ) : null} - -
- -
- 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" /> -
- - -
+
-
- -
- {CAPABILITY_OPTIONS.map((cap) => ( +
+ + + 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" + /> +
+ +
+ + + 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" + /> +

+ Supported JSON: OpenAI `/models` shape with a `data` array, or a + direct model array. +

+
+ +
+ +
+ {form.models.map((model) => ( + + {model} + + ))} + 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" + /> +
+

+ Add at least one model or set a Model Source URL. +

+
+ + {form.models.length > 1 ? ( +
+ + +
+ ) : null} + +
+ +
+ + 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" + /> +
+ +
+
-
- + > + {cap.replace(/-/g, " ")} + + ))} +
+
+ +
+ - {showAdvanced ? ( -
-
- - - 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" - /> -
+ {showAdvanced ? ( +
+
+ + + 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" + /> +
-
- -
- {Object.entries(form.headers).map(([key, value], idx) => ( -
- - 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" - /> - - 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" - /> - -
- ))} - -
+
+ +
+ {Object.entries(form.headers).map(([key, value], idx) => ( +
+ + 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" + /> + 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" + /> + +
+ ))} +
- ) : null} -
+
+ ) : null} +
- {error ?

{error}

: null} + {error ?

{error}

: null} -
- - -
+
+ +
- + ); } diff --git a/apps/cline-hub/src/webview/src/components/views/settings/channels-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/channels-view.tsx index 971297b873..967e34b596 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/channels-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/channels-view.tsx @@ -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 ( - -
-
-
-

Channels

-

- {activeConnectors.length} connected -

-
-
+ + cline connect} + actions={ + <> -
+ + } + /> + + {errorMessage ? ( +
+ {errorMessage}
+ ) : null} - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - + {isLoading ? ( + Loading channels... + ) : activeConnectors.length === 0 ? ( + No channels connected. + ) : (
- {isLoading ? ( -

- Loading channels... -

- ) : activeConnectors.length === 0 ? ( -

- No channels connected. -

- ) : ( - activeConnectors.map((connector) => ( -
-
-
- -

- {connectorName(connector, channels)} -

- - {connectorIdentity(connector)} - -
-
- - pid={connector.pid} - + {activeConnectors.map((connector) => ( +
+
+
+ +

+ {connectorName(connector, channels)} +

+ + {connectorIdentity(connector)} + +
+
+ + pid={connector.pid} + + + {connector.hubUrl} + + {connector.baseUrl ? ( - {connector.hubUrl} + {connector.baseUrl} - {connector.baseUrl ? ( - - {connector.baseUrl} - - ) : null} + ) : null} + + {formatDateTime(connector.startedAt)} + + {connector.connectionMode ? ( - {formatDateTime(connector.startedAt)} + {connector.connectionMode} - {connector.connectionMode ? ( - - {connector.connectionMode} - - ) : null} -
+ ) : null}
-
- )) - )} + +
+ ))}
-
+ )} @@ -639,6 +637,6 @@ export function ChannelsContent() { - + ); } diff --git a/apps/cline-hub/src/webview/src/components/views/settings/extensions-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/extensions-view.tsx index 521d5877ad..73dcd3fd0b 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/extensions-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/extensions-view.tsx @@ -4,29 +4,58 @@ import { Bot, Code, FileText, - FolderOpen, Play, Puzzle, RefreshCw, + Server, + Trash2, TriangleAlert, Wrench, Zap, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { desktopClient } from "@/lib/desktop-client"; +import type { MarketplacePrimitiveType } from "@/lib/marketplace"; import { cn } from "@/lib/utils"; +import { + type MarketplaceLocalInstalledItem, + MarketplaceView, +} from "../marketplace-view"; +import { CommandBadge, PageFrame, PageHeader } from "../page-layout"; -type ShortcutTab = +export type CustomizationSection = | "Rules" | "Hooks" + | "MCP" | "Skills" | "Agents" | "Plugins" | "Tools"; +const sectionDescriptions: Record = { + Rules: "Review project and global rule files that shape Cline behavior.", + Hooks: "Inspect hook configuration and recent execution status.", + MCP: "Manage installed MCP servers and add new servers from the catalog.", + Skills: "Manage installed skills and add new skills from the catalog.", + Agents: "Review configured agents discovered from local settings.", + Plugins: "Manage installed plugins and add new plugins from the catalog.", + Tools: "Inspect built-in tools and tools contributed by plugins.", +}; + +const sectionCommands: Record = { + Rules: "cline config rules", + Hooks: "cline config hooks", + MCP: "cline mcp install", + Skills: "cline skill", + Agents: "cline config agents", + Plugins: "cline plugin", + Tools: "cline config tools", +}; + type RuleItem = { name: string; instructions: string; @@ -54,8 +83,11 @@ type CommandItem = { description?: string; instructions: string; path: string; + scope: ItemScope; }; +type ItemScope = "Global" | "Project"; + type AgentItem = { name: string; path: string; @@ -118,6 +150,16 @@ type McpServersResponse = { servers: McpServer[]; }; +type LocalUninstallType = "mcp" | "skill" | "workflow" | "plugin"; + +type LocalUninstallTarget = { + key: string; + type: LocalUninstallType; + id?: string; + name?: string; + path?: string; +}; + type UserInstructionListsResponse = { workspaceRoot: string; rules: RuleItem[]; @@ -131,6 +173,12 @@ type UserInstructionListsResponse = { warnings: string[]; }; +const EMPTY_MCP_RESPONSE: McpServersResponse = { + settingsPath: "", + hasSettingsFile: false, + servers: [], +}; + function asArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } @@ -159,9 +207,7 @@ function normalizeInstructionListsResponse( servers: asArray(response.mcp.servers), } : { - settingsPath: "", - hasSettingsFile: false, - servers: [], + ...EMPTY_MCP_RESPONSE, }, warnings: asArray(response?.warnings), }; @@ -206,6 +252,43 @@ function normalizePath(path: string): string { return path.replaceAll("\\", "/"); } +function getPathScope(path: string, workspaceRoot: string): ItemScope { + const normalizedRoot = normalizePath(workspaceRoot); + const normalized = normalizePath(path); + return normalizedRoot && normalized.startsWith(`${normalizedRoot}/`) + ? "Project" + : "Global"; +} + +function ScopeBadge({ scope }: { scope: ItemScope }) { + return ( + + {scope} + + ); +} + +function getLocalMarketplaceMatchValues( + ...values: Array +): string[] { + const matchValues = new Set(); + for (const value of values) { + const normalized = value?.trim().replaceAll("\\", "/"); + if (!normalized) { + continue; + } + matchValues.add(normalized); + const segments = normalized.split("/").filter(Boolean); + for (const segment of segments) { + matchValues.add(segment.replace(/\.[^.]+$/, "")); + } + for (let index = 0; index < segments.length - 1; index += 1) { + matchValues.add(`${segments[index]}/${segments[index + 1]}`); + } + } + return [...matchValues]; +} + async function fetchUserInstructionLists(): Promise { const response = await desktopClient.invoke< Partial @@ -218,8 +301,16 @@ function isUnsupportedDesktopCommand(error: unknown, command: string): boolean { return message.includes(`unsupported desktop command: ${command}`); } -export function RulesView() { - const [activeTab, setActiveTab] = useState("Rules"); +export function CustomizationSectionView({ + catalogPrimitive, + section = "Rules", + showTabs = false, +}: { + catalogPrimitive?: MarketplacePrimitiveType; + section?: CustomizationSection; + showTabs?: boolean; +}) { + const [activeTab, setActiveTab] = useState(section); const [isLoading, setIsLoading] = useState( () => !hasFreshExtensionsListsCache(extensionListsCache, Date.now()), ); @@ -248,6 +339,9 @@ export function RulesView() { const [hooks, setHooks] = useState( () => extensionListsCache?.hooks ?? [], ); + const [mcp, setMcp] = useState( + () => extensionListsCache?.mcp ?? EMPTY_MCP_RESPONSE, + ); const [warnings, setWarnings] = useState( () => extensionListsCache?.warnings ?? [], ); @@ -264,6 +358,16 @@ export function RulesView() { const [togglingPluginPaths, setTogglingPluginPaths] = useState>( () => new Set(), ); + const [localUninstallingKeys, setLocalUninstallingKeys] = useState< + Set + >(() => new Set()); + const [localActionMessages, setLocalActionMessages] = useState< + Map + >(() => new Map()); + + useEffect(() => { + setActiveTab(section); + }, [section]); const refresh = useCallback(async (force = false) => { const now = Date.now(); @@ -276,6 +380,7 @@ export function RulesView() { setPlugins(extensionListsCache.plugins); setTools(extensionListsCache.tools); setHooks(extensionListsCache.hooks); + setMcp(extensionListsCache.mcp); setWarnings(extensionListsCache.warnings); setErrorMessage(null); setIsLoading(false); @@ -294,6 +399,7 @@ export function RulesView() { setPlugins(response.plugins); setTools(response.tools); setHooks(response.hooks); + setMcp(response.mcp); setWarnings(response.warnings); extensionListsCache = { ...response, @@ -396,6 +502,7 @@ export function RulesView() { setPlugins(normalizedResponse.plugins); setTools(normalizedResponse.tools); setHooks(normalizedResponse.hooks); + setMcp(normalizedResponse.mcp); setWarnings(normalizedResponse.warnings); extensionListsCache = { ...normalizedResponse, @@ -484,6 +591,58 @@ export function RulesView() { [applyResponse], ); + const uninstallLocalPrimitive = useCallback( + async (target: LocalUninstallTarget) => { + if (localUninstallingKeys.has(target.key)) { + return; + } + setLocalUninstallingKeys((current) => new Set(current).add(target.key)); + setLocalActionMessages((current) => { + const next = new Map(current); + next.delete(target.key); + return next; + }); + setErrorMessage(null); + try { + const result = await desktopClient.invoke<{ message?: string }>( + "uninstall_local_primitive", + { + type: target.type, + id: target.id, + name: target.name, + path: target.path, + }, + ); + setLocalActionMessages((current) => { + const next = new Map(current); + next.set(target.key, { + status: "success", + message: + result.message ?? + `Uninstalled ${target.name ?? target.id ?? target.path}.`, + }); + return next; + }); + await refresh(true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setLocalActionMessages((current) => { + const next = new Map(current); + next.set(target.key, { status: "failed", message }); + return next; + }); + setErrorMessage(message); + } finally { + setLocalUninstallingKeys((current) => { + const next = new Set(current); + next.delete(target.key); + return next; + }); + } + }, + [localUninstallingKeys, refresh], + ); + const formatExecutionTs = useCallback((value: string | null): string => { if (!value) { return "never"; @@ -515,7 +674,7 @@ export function RulesView() { return () => window.clearTimeout(timeoutId); }, [activeTab, loadHookExecutionStats]); - const tabs: ShortcutTab[] = [ + const tabs: CustomizationSection[] = [ "Rules", "Hooks", "Skills", @@ -531,6 +690,7 @@ export function RulesView() { name: workflow.name, instructions: workflow.instructions, path: workflow.path, + scope: getPathScope(workflow.path, workspaceRoot), })); const skillItems: CommandItem[] = skills.map((skill) => ({ id: normalizePath(skill.path).toLowerCase(), @@ -539,11 +699,12 @@ export function RulesView() { description: skill.description, instructions: skill.instructions, path: skill.path, + scope: getPathScope(skill.path, workspaceRoot), })); return [...workflowItems, ...skillItems].sort((a, b) => a.name.localeCompare(b.name), ); - }, [skills, workflows]); + }, [skills, workflows, workspaceRoot]); const { projectRules, globalRules } = useMemo(() => { const normalizedRoot = normalizePath(workspaceRoot); @@ -624,13 +785,410 @@ export function RulesView() { return grouped; }, [pluginTools]); + const scopedPlugins = useMemo( + () => [ + ...globalPlugins.map((plugin) => ({ + plugin, + scope: "Global" as ItemScope, + })), + ...projectPlugins.map((plugin) => ({ + plugin, + scope: "Project" as ItemScope, + })), + ], + [globalPlugins, projectPlugins], + ); + + const scopedRules = useMemo( + () => [ + ...globalRules.map((rule) => ({ rule, scope: "Global" as ItemScope })), + ...projectRules.map((rule) => ({ rule, scope: "Project" as ItemScope })), + ], + [globalRules, projectRules], + ); + + const scopedHooks = useMemo( + () => [ + ...globalHooks.map((hook) => ({ hook, scope: "Global" as ItemScope })), + ...projectHooks.map((hook) => ({ hook, scope: "Project" as ItemScope })), + ], + [globalHooks, projectHooks], + ); + + const renderLocalActionMessage = (key: string) => { + const message = localActionMessages.get(key); + if (!message) { + return null; + } + return ( + + {message.message} + + ); + }; + + const renderLocalActionRow = (target: LocalUninstallTarget) => { + const uninstalling = localUninstallingKeys.has(target.key); + return ( +
+
+ {renderLocalActionMessage(target.key)} +
+ +
+ ); + }; + + const renderSkillCard = (item: CommandItem) => { + const key = `${item.type}:${item.path}`; + return ( +
+
+ {item.type === "workflow" ? ( + + ) : ( + + )} +

+ {item.name} +

+ + + {item.type} + +
+

+ {item.description?.trim() || previewText(item.instructions)} +

+

+ {item.path} +

+
+ {renderLocalActionRow({ + key, + type: item.type, + id: item.id, + name: item.name, + path: item.path, + })} +
+
+ ); + }; + + const renderSkillMatchedDetails = (item: CommandItem) => ( +
+

+ {item.description?.trim() || previewText(item.instructions)} +

+

{item.path}

+
+ ); + + const renderPluginCard = ({ + plugin, + scope, + }: { + plugin: PluginItem; + scope: ItemScope; + }) => { + const key = plugin.path; + return ( +
+
+ +

+ {plugin.name} +

+ + + {plugin.enabled ? "Enabled" : "Disabled"} + + { + void setPluginEnabled(plugin); + }} + disabled={togglingPluginPaths.has(plugin.path)} + aria-label={`Toggle ${plugin.name}`} + /> +
+

+ {plugin.path} +

+
+ {( + pluginToolsByPluginKey.get(`${plugin.name}:${plugin.path}`) ?? [] + ).map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling || !plugin.enabled} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} + {(pluginToolsByPluginKey.get(`${plugin.name}:${plugin.path}`) + ?.length ?? 0) === 0 && ( +

+ No plugin tools found. +

+ )} +
+
+ {renderLocalActionRow({ + key, + type: "plugin", + id: plugin.name, + name: plugin.name, + path: plugin.path, + })} +
+
+ ); + }; + + const renderPluginMatchedControls = (plugin: PluginItem) => ( + <> + + {plugin.enabled ? "Enabled" : "Disabled"} + + { + void setPluginEnabled(plugin); + }} + disabled={togglingPluginPaths.has(plugin.path)} + aria-label={`Toggle ${plugin.name}`} + /> + + ); + + const renderPluginMatchedMeta = (plugin: PluginItem) => ( +

+ {plugin.path} +

+ ); + + const renderPluginMatchedDetails = (plugin: PluginItem) => { + const pluginTools = + pluginToolsByPluginKey.get(`${plugin.name}:${plugin.path}`) ?? []; + if (pluginTools.length === 0) { + return null; + } + return ( +
+ {pluginTools.map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling || !plugin.enabled} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} +
+ ); + }; + + const renderMcpServerCard = (server: McpServer) => { + const key = server.name; + return ( +
+
+ +

+ {server.name} +

+ + + {server.transportType} + + + {server.disabled ? "Disabled" : "Enabled"} + +
+

+ {server.url ?? + ([server.command, ...(server.args ?? [])] + .filter(Boolean) + .join(" ") || + "No launch command configured.")} +

+ {mcp.settingsPath ? ( +

+ {mcp.settingsPath} +

+ ) : null} +
+ {renderLocalActionRow({ + key, + type: "mcp", + id: server.name, + name: server.name, + })} +
+
+ ); + }; + + const renderMcpMatchedDetails = (server: McpServer) => ( +
+

+ {server.disabled ? "Disabled" : "Enabled"} locally + {server.transportType ? ` via ${server.transportType}` : ""} +

+ {mcp.settingsPath ? ( +

+ {mcp.settingsPath} +

+ ) : null} +
+ ); + + const installedCatalogLocalItems = + catalogPrimitive === "skill" + ? commandItems.map( + (item): MarketplaceLocalInstalledItem => ({ + key: `${item.type}:${item.path}`, + matchValues: getLocalMarketplaceMatchValues( + item.id, + item.name, + item.path, + ), + render: () => renderSkillCard(item), + renderMatchedBadges: () => ( + <> + + + {item.type} + + + ), + renderMatchedDetails: () => renderSkillMatchedDetails(item), + }), + ) + : catalogPrimitive === "plugin" + ? scopedPlugins.map( + (item): MarketplaceLocalInstalledItem => ({ + key: item.plugin.path, + matchValues: getLocalMarketplaceMatchValues( + item.plugin.name, + item.plugin.path, + ), + render: () => renderPluginCard(item), + renderMatchedBadges: () => , + renderMatchedControls: () => + renderPluginMatchedControls(item.plugin), + renderMatchedDetails: + ( + pluginToolsByPluginKey.get( + `${item.plugin.name}:${item.plugin.path}`, + ) ?? [] + ).length > 0 + ? () => renderPluginMatchedDetails(item.plugin) + : undefined, + renderMatchedMeta: () => renderPluginMatchedMeta(item.plugin), + }), + ) + : catalogPrimitive === "mcp" + ? mcp.servers.map( + (server): MarketplaceLocalInstalledItem => ({ + key: server.name, + matchValues: getLocalMarketplaceMatchValues(server.name), + render: () => renderMcpServerCard(server), + renderMatchedBadges: () => ( + <> + + + {server.transportType} + + + ), + renderMatchedDetails: () => renderMcpMatchedDetails(server), + }), + ) + : null; return ( - -
-
-

- Customizations -

+ + {sectionCommands[activeTab]}} + actions={ -
+ } + /> + {showTabs ? (
{tabs.map((tab) => (
+ ) : null} - {errorMessage && ( -
- Failed to load configuration lists: {errorMessage} + {errorMessage && ( +
+ Failed to load configuration lists: {errorMessage} +
+ )} + + {warnings.length > 0 && ( +
+
+ + Partial results
- )} +
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} - {warnings.length > 0 && ( -
-
- - Partial results + {catalogPrimitive ? ( + refresh(true)} + primitive={catalogPrimitive} + /> + ) : null} + + {activeTab === "Rules" && ( +
+

+ Enabled rules discovered from configured workspace/global + directories. +

+ +
+
+

+ Installed +

+ + {scopedRules.length} +
-
    - {warnings.map((warning) => ( -
  • {warning}
  • +
    + {scopedRules.map(({ rule, scope }) => ( +
    +
    + + + {rule.name} + + +
    +

    + {previewText(rule.instructions)} +

    +

    + {rule.path} +

    +
    ))} -
-
- )} - - {activeTab === "Rules" && ( -
-

- Enabled rules discovered from configured workspace/global - directories. -

- -
-

- Global Rules -

-
- {globalRules.map((rule) => ( -
-
- - - {rule.name} - -
-

- {previewText(rule.instructions)} -

-

- {rule.path} -

-
- ))} - {globalRules.length === 0 && ( -

- No global rules found. -

- )} -
-
- -
-

- Project Rules -

-
- {projectRules.map((rule) => ( -
-
- - - {rule.name} - -
-

- {previewText(rule.instructions)} -

-

- {rule.path} -

-
- ))} - {projectRules.length === 0 && ( -

- No project rules found. -

- )} -
+ {scopedRules.length === 0 && ( +

+ No rules found. +

+ )}
- )} +
+ )} - {activeTab === "Hooks" && ( -
-

- Hook config files from workspace and global hook directories. + {activeTab === "Hooks" && ( +

+

+ Hook config files from workspace and global hook directories. +

+ {hookExecutionLoading && hookExecutionSessionId && ( +

+ Execution status is based on hook events in session{" "} + {hookExecutionSessionId}.

- {hookExecutionLoading && hookExecutionSessionId && ( -

- Execution status is based on hook events in session{" "} - {hookExecutionSessionId}. + )} + +

+
+

+ Installed +

+ + {scopedHooks.length} + +
+
+ {scopedHooks.map(({ hook, scope }) => ( +
+
+ + + {hook.fileName} + + + {hook.hookEventName && ( +
+ + {hook.hookEventName} + + {(() => { + const stats = + hookExecutionByEvent[hook.hookEventName]; + const executed = (stats?.count ?? 0) > 0; + return ( + + {executed + ? `${stats?.count ?? 0} executed` + : "never executed"} + + ); + })()} +
+ )} +
+ {hook.hookEventName ? ( +

+ Last run:{" "} + {formatExecutionTs( + hookExecutionByEvent[hook.hookEventName]?.lastTs ?? + null, + )} +

+ ) : null} +

+ {hook.path} +

+
+ ))} + {scopedHooks.length === 0 && ( +

+ No hooks found. +

+ )} +
+
+
+ )} + + {activeTab === "Skills" && !catalogPrimitive && ( +
+

+ Skills can be invoked in chat with{" "} + + /SKILL + + . +

+ +
+ {commandItems.map((item) => ( +
+
+ {item.type === "workflow" ? ( + + ) : ( + + )} +

+ {item.name} +

+ + {item.type} + +
+

+ {item.description?.trim() || previewText(item.instructions)} +

+

+ {item.path} +

+
+ ))} + {commandItems.length === 0 && ( +

+ No enabled skills or workflows found.

)} - -
-

- Global Hooks -

-
- {globalHooks.map((hook) => ( -
-
- - - {hook.fileName} - - {hook.hookEventName && ( -
- - {hook.hookEventName} - - {(() => { - const stats = - hookExecutionByEvent[hook.hookEventName]; - const executed = (stats?.count ?? 0) > 0; - return ( - - {executed - ? `${stats?.count ?? 0} executed` - : "never executed"} - - ); - })()} -
- )} -
- {hook.hookEventName ? ( -

- Last run:{" "} - {formatExecutionTs( - hookExecutionByEvent[hook.hookEventName]?.lastTs ?? - null, - )} -

- ) : null} -

- {hook.path} -

-
- ))} - {globalHooks.length === 0 && ( -

- No global hooks found. -

- )} -
-
- -
-
- - Project Hooks -
-
- {projectHooks.map((hook) => ( -
-
- - - {hook.fileName} - - {hook.hookEventName && ( -
- - {hook.hookEventName} - - {(() => { - const stats = - hookExecutionByEvent[hook.hookEventName]; - const executed = (stats?.count ?? 0) > 0; - return ( - - {executed - ? `${stats?.count ?? 0} executed` - : "never executed"} - - ); - })()} -
- )} -
- {hook.hookEventName ? ( -

- Last run:{" "} - {formatExecutionTs( - hookExecutionByEvent[hook.hookEventName]?.lastTs ?? - null, - )} -

- ) : null} -

- {hook.path} -

-
- ))} - {projectHooks.length === 0 && ( -

- No project hooks found. -

- )} -
-
- )} +
+ )} - {activeTab === "Skills" && ( -
-

- Skills can be invoked in chat with{" "} - - /SKILL - - . -

+ {activeTab === "Agents" && ( +
+

+ Configured agents discovered from Documents and settings + directories. +

+
+ {agents.map((agent) => ( +
+
+ +

+ {agent.name} +

+
+

+ {agent.path} +

+
+ ))} + {agents.length === 0 && ( +

+ No configured agents found. +

+ )} +
+
+ )} + + {activeTab === "Plugins" && !catalogPrimitive && ( +
+

+ Plugins discovered from workspace and global plugin directories. +

+ +
+

+ Global Plugins +

- {commandItems.map((item) => ( + {globalPlugins.map((plugin) => (
- {item.type === "workflow" ? ( - - ) : ( - - )} -

- {item.name} + +

+ {plugin.name}

- - {item.type} + + {plugin.enabled ? "Enabled" : "Disabled"} + { + void setPluginEnabled(plugin); + }} + disabled={togglingPluginPaths.has(plugin.path)} + aria-label={`Toggle ${plugin.name}`} + />

-

- {item.description?.trim() || previewText(item.instructions)} -

- {item.path} + {plugin.path}

+
+ {( + pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + ) ?? [] + ).map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || + "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling || !plugin.enabled} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} + {(pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + )?.length ?? 0) === 0 && ( +

+ No plugin tools found. +

+ )} +
))} - {commandItems.length === 0 && ( + {globalPlugins.length === 0 && (

- No enabled skills or workflows found. + No global plugins found.

)}
- )} - {activeTab === "Agents" && (
-

- Configured agents discovered from Documents and settings - directories. -

- +

+ Project Plugins +

- {agents.map((agent) => ( + {projectPlugins.map((plugin) => (
- -

- {agent.name} + +

+ {plugin.name}

+ + {plugin.enabled ? "Enabled" : "Disabled"} + + { + void setPluginEnabled(plugin); + }} + disabled={togglingPluginPaths.has(plugin.path)} + aria-label={`Toggle ${plugin.name}`} + />

- {agent.path} + {plugin.path}

+
+ {( + pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + ) ?? [] + ).map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || + "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling || !plugin.enabled} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} + {(pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + )?.length ?? 0) === 0 && ( +

+ No plugin tools found. +

+ )} +
))} - {agents.length === 0 && ( + {projectPlugins.length === 0 && (

- No configured agents found. + No project plugins found.

)}
- )} +
+ )} - {activeTab === "Plugins" && ( -
-

- Plugins discovered from workspace and global plugin directories. -

+ {activeTab === "Tools" && ( +
+

+ Builtin tool groups and plugin-contributed tools available to the + runtime. +

-
-

- Global Plugins -

-
- {globalPlugins.map((plugin) => ( -
-
- -

- {plugin.name} -

- - {plugin.enabled ? "Enabled" : "Disabled"} - - { - void setPluginEnabled(plugin); - }} - disabled={togglingPluginPaths.has(plugin.path)} - aria-label={`Toggle ${plugin.name}`} - /> -
-

- {plugin.path} -

-
- {( - pluginToolsByPluginKey.get( - `${plugin.name}:${plugin.path}`, - ) ?? [] - ).map((tool) => { - const isToggling = togglingToolIds.has(tool.id); - return ( -
-
-

- {tool.name} -

-

- {tool.description?.trim() || - "No description available."} -

-
-
- - {tool.enabled ? "Enabled" : "Disabled"} - - { - void setToolEnabled(tool); - }} - disabled={isToggling || !plugin.enabled} - aria-label={`Toggle ${tool.name}`} - /> -
-
- ); - })} - {(pluginToolsByPluginKey.get( - `${plugin.name}:${plugin.path}`, - )?.length ?? 0) === 0 && ( -

- No plugin tools found. +

+

+ Builtin Tools +

+
+ {builtinTools.map((tool) => + (() => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+ +

+ {tool.name} +

+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling} + aria-label={`Toggle ${tool.name}`} + /> +
+

+ {tool.description?.trim() || + "No description available."} +

+ {!!tool.headlessToolNames?.length && ( +

+ {tool.headlessToolNames.join(", ")}

)}
-
- ))} - {globalPlugins.length === 0 && ( -

- No global plugins found. -

- )} -
+ ); + })(), + )} + {builtinTools.length === 0 && ( +

+ No builtin tools found. +

+ )}
+
-
-

- Project Plugins -

-
- {projectPlugins.map((plugin) => ( -
-
- -

- {plugin.name} -

- - {plugin.enabled ? "Enabled" : "Disabled"} - - { - void setPluginEnabled(plugin); - }} - disabled={togglingPluginPaths.has(plugin.path)} - aria-label={`Toggle ${plugin.name}`} - /> -
-

- {plugin.path} -

-
- {( - pluginToolsByPluginKey.get( - `${plugin.name}:${plugin.path}`, - ) ?? [] - ).map((tool) => { - const isToggling = togglingToolIds.has(tool.id); - return ( -
-
-

- {tool.name} -

-

- {tool.description?.trim() || - "No description available."} -

-
-
- - {tool.enabled ? "Enabled" : "Disabled"} - - { - void setToolEnabled(tool); - }} - disabled={isToggling || !plugin.enabled} - aria-label={`Toggle ${tool.name}`} - /> -
-
- ); - })} - {(pluginToolsByPluginKey.get( - `${plugin.name}:${plugin.path}`, - )?.length ?? 0) === 0 && ( -

- No plugin tools found. +

+

+ Plugin Tools +

+
+ {pluginTools.map((tool) => + (() => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+ +

+ {tool.name} +

+ {tool.pluginName && ( + + plugin: {tool.pluginName} + + )} + + {tool.enabled ? "Enabled" : "Disabled"} + + { + void setToolEnabled(tool); + }} + disabled={isToggling} + aria-label={`Toggle ${tool.name}`} + /> +
+

+ {tool.description?.trim() || + "No description available."} +

+ {tool.path && ( +

+ {tool.path}

)}
-
- ))} - {projectPlugins.length === 0 && ( -

- No project plugins found. -

- )} -
+ ); + })(), + )} + {pluginTools.length === 0 && ( +

+ No plugin tools found. +

+ )}
- )} - - {activeTab === "Tools" && ( -
-

- Builtin tool groups and plugin-contributed tools available to the - runtime. -

- -
-

- Builtin Tools -

-
- {builtinTools.map((tool) => - (() => { - const isToggling = togglingToolIds.has(tool.id); - return ( -
-
- -

- {tool.name} -

- - {tool.enabled ? "Enabled" : "Disabled"} - - { - void setToolEnabled(tool); - }} - disabled={isToggling} - aria-label={`Toggle ${tool.name}`} - /> -
-

- {tool.description?.trim() || - "No description available."} -

- {!!tool.headlessToolNames?.length && ( -

- {tool.headlessToolNames.join(", ")} -

- )} -
- ); - })(), - )} - {builtinTools.length === 0 && ( -

- No builtin tools found. -

- )} -
-
- -
-

- Plugin Tools -

-
- {pluginTools.map((tool) => - (() => { - const isToggling = togglingToolIds.has(tool.id); - return ( -
-
- -

- {tool.name} -

- {tool.pluginName && ( - - plugin: {tool.pluginName} - - )} - - {tool.enabled ? "Enabled" : "Disabled"} - - { - void setToolEnabled(tool); - }} - disabled={isToggling} - aria-label={`Toggle ${tool.name}`} - /> -
-

- {tool.description?.trim() || - "No description available."} -

- {tool.path && ( -

- {tool.path} -

- )} -
- ); - })(), - )} - {pluginTools.length === 0 && ( -

- No plugin tools found. -

- )} -
-
-
- )} -
- +
+ )} + ); } + +export function RulesView() { + return ; +} diff --git a/apps/cline-hub/src/webview/src/components/views/settings/mcp-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/mcp-view.tsx index d04d66ab78..62968d4ad1 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/mcp-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/mcp-view.tsx @@ -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 ( - -
-
-
-

- MCP Servers -

- + + + cline config mcp + From settings file -
-
+ + } + actions={ + <> -
+ + } + /> + +
+ MCP settings path: + +
+ {errorMessage && ( +
+ {errorMessage}
+ )} -
- MCP settings path: - + {isLoading ? ( +
+ Loading MCP servers...
-

- {hasSettingsFile - ? "Editing this list updates cline_mcp_settings.json." - : "No MCP settings file found yet. Add a server to create it."} -

- - {errorMessage && ( -
- {errorMessage} -
- )} - - {isLoading ? ( -
- Loading MCP servers... -
- ) : sortedServers.length === 0 ? ( -
- No MCP servers configured. -
- ) : ( -
- {sortedServers.map((server) => { - const isBusy = busyServerName === server.name; - return ( -
-
- + No MCP servers configured. +
+ ) : ( +
+ {sortedServers.map((server) => { + const isBusy = busyServerName === server.name; + return ( +
+
+ +

+ {server.name} +

+ + {server.transportType} + +
+
+ + + + toggleServer(server, !enabled) + } + disabled={isBusy} + aria-label={`Enable ${server.name}`} /> -

- {server.name} -

- - {server.transportType} - -
-
- - - - toggleServer(server, !enabled) - } - disabled={isBusy} - aria-label={`Enable ${server.name}`} - /> -
-
- -
- {server.command && ( -

- - Command: - {" "} - {server.command} -

- )} - {server.args && server.args.length > 0 && ( -

- Args:{" "} - {server.args.join(", ")} -

- )} - {server.cwd && ( -

- CWD:{" "} - {server.cwd} -

- )} - {server.url && ( -

- URL:{" "} - {server.url} -

- )} - {server.env && Object.keys(server.env).length > 0 && ( -

- Env:{" "} - {stringifyRedactedKeyValuePairs(server.env)} -

- )} - {server.headers && - Object.keys(server.headers).length > 0 && ( -

- - Headers: - {" "} - {stringifyKeyValuePairs(server.headers)} -

- )}
- ); - })} -
- )} -
+
+ {server.command && ( +

+ Command:{" "} + {server.command} +

+ )} + {server.args && server.args.length > 0 && ( +

+ Args:{" "} + {server.args.join(", ")} +

+ )} + {server.cwd && ( +

+ CWD:{" "} + {server.cwd} +

+ )} + {server.url && ( +

+ URL:{" "} + {server.url} +

+ )} + {server.env && Object.keys(server.env).length > 0 && ( +

+ Env:{" "} + {stringifyRedactedKeyValuePairs(server.env)} +

+ )} + {server.headers && Object.keys(server.headers).length > 0 && ( +

+ Headers:{" "} + {stringifyKeyValuePairs(server.headers)} +

+ )} +
+
+ ); + })} +
+ )} { @@ -854,6 +848,6 @@ export function McpServersContent() { - + ); } diff --git a/apps/cline-hub/src/webview/src/components/views/settings/provider-list-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/provider-list-view.tsx index 0d2bbc9d04..c14eec1556 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/provider-list-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/provider-list-view.tsx @@ -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 ( -
-
-

- Model Providers -

- +
+
+
+

+ Models +

+

+ Configure model providers and choose which ones are available.{" "} + {providers.length} available · {enabledProviderCount}{" "} + enabled +

+
+
+ + +
-
- {providers.map((prov) => ( + {providerSearchOpen ? ( +
+
+ + setProviderSearch(event.target.value)} + placeholder="Search providers" + value={providerSearch} + /> +
+
+ ) : null} + +
+ {filteredProviders.length === 0 ? ( +
+ No providers match "{providerSearch.trim()}". +
+ ) : null} + {filteredProviders.map((prov) => (
-
-

+

- + onToggle(prov.id)} /> +
))}
@@ -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>({}); 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 ( -
+
{/* Back + title */}
-

+

{provider.name} -

+
{configFields.length > 0 ? ( -
-
+
+
{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 ( -
-
-

+
+
+

{field.label}

{field.description ? ( -

+

{field.description}

) : null}
{field.type === "boolean" ? ( -
+
{field.label} @@ -300,7 +409,7 @@ export function ProviderDetailContent({
) : field.type === "select" ? ( ) : ( -
+
{field.type === "url" ? ( ) : null} commitField(field, valueText)} onChange={(event) => setLocalConfigValues((current) => ({ @@ -408,10 +517,18 @@ export function ProviderDetailContent({ ) : null} {/* Models section */} -
-
-

Models

+
+
+

+ Models +

+ -
+ + } + /> + + {errorMessage && ( +
+ {errorMessage}
+ )} -

- Scheduled jobs are run through the hub. -

- - {errorMessage && ( -
- {errorMessage} -
- )} - - {isLoading ? ( -
- Loading routines... -
- ) : sortedSchedules.length === 0 ? ( -
- No schedules found. Create a schedule to run routines on a recurring - basis. -
- ) : ( -
- {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 ( -
-
- Loading schedules... + ) : sortedSchedules.length === 0 ? ( + + No schedules found. Create a schedule to run routines on a recurring + basis. + + ) : ( +
+ {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 ( +
+
+ +

+ {schedule.name} +

+ + {schedule.mode} + + + {schedule.cronPattern} + +
+
+ + + + + + + void upsertScheduleEnabled(schedule, checked) + } + disabled={isBusy} + aria-label={`Enable ${schedule.name}`} /> -

- {schedule.name} -

- - {schedule.mode} - - - {schedule.cronPattern} - -
-
- - - - - - - void upsertScheduleEnabled(schedule, checked) - } - disabled={isBusy} - aria-label={`Enable ${schedule.name}`} - /> -
-
- -
-

- ID:{" "} - {schedule.scheduleId} -

-

- Prompt:{" "} - {schedule.prompt} -

-

- Model:{" "} - {formatScheduleModel(schedule)} -

- {schedule.workspaceRoot && ( -

- - Workspace: - {" "} - {schedule.workspaceRoot} -

- )} - {schedule.cwd && ( -

- CWD:{" "} - {schedule.cwd} -

- )} -

- - Last run: - {" "} - {formatDateTime(schedule.lastRunAt)} -

-

- - Last result: - {" "} - {formatExecutionResult(lastExecution)} -

- {lastExecution?.sessionId && ( -

- - Last session: - {" "} - {lastExecution.sessionId} -

- )} - {lastExecution?.errorMessage && ( -

- - Last error: - {" "} - {lastExecution.errorMessage} -

- )} -

- - Next run: - {" "} - {formatDateTime( - schedule.nextRunAt || upcoming?.nextRunAt, - )} -

- {activeExecution && ( -

- - Active: - {" "} - {activeExecution.executionId} since{" "} - {formatDateTime(activeExecution.startedAt)} -

- )} - {schedule.tags && schedule.tags.length > 0 && ( -

- Tags:{" "} - {schedule.tags.join(", ")} -

- )}
- ); - })} -
- )} -
+ +
+

+ ID:{" "} + {schedule.scheduleId} +

+

+ Prompt:{" "} + {schedule.prompt} +

+

+ Model:{" "} + {formatScheduleModel(schedule)} +

+ {schedule.workspaceRoot && ( +

+ + Workspace: + {" "} + {schedule.workspaceRoot} +

+ )} + {schedule.cwd && ( +

+ CWD:{" "} + {schedule.cwd} +

+ )} +

+ Last run:{" "} + {formatDateTime(schedule.lastRunAt)} +

+

+ + Last result: + {" "} + {formatExecutionResult(lastExecution)} +

+ {lastExecution?.sessionId && ( +

+ + Last session: + {" "} + {lastExecution.sessionId} +

+ )} + {lastExecution?.errorMessage && ( +

+ + Last error: + {" "} + {lastExecution.errorMessage} +

+ )} +

+ Next run:{" "} + {formatDateTime(schedule.nextRunAt || upcoming?.nextRunAt)} +

+ {activeExecution && ( +

+ Active:{" "} + {activeExecution.executionId} since{" "} + {formatDateTime(activeExecution.startedAt)} +

+ )} + {schedule.tags && schedule.tags.length > 0 && ( +

+ Tags:{" "} + {schedule.tags.join(", ")} +

+ )} +
+
+ ); + })} +
+ )} { @@ -1554,6 +1535,6 @@ export function RoutineSchedulesContent() {

- + ); } diff --git a/apps/cline-hub/src/webview/src/components/views/settings/settings-view.tsx b/apps/cline-hub/src/webview/src/components/views/settings/settings-view.tsx index c209b9662a..c30f69506e 100644 --- a/apps/cline-hub/src/webview/src/components/views/settings/settings-view.tsx +++ b/apps/cline-hub/src/webview/src/components/views/settings/settings-view.tsx @@ -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(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 ? ( + provider.id)} + onBack={backToProviderList} + onSave={saveNewProvider} + /> + ) : providersLoading ? ( +
+

Loading providers...

+
+ ) : providerCatalogError ? ( +
+

+ Failed to load providers: {providerCatalogError} +

+
+ ) : selectedProvider ? ( +
+ + +
+ ) : ( + + ); + + const content = + activeNav === "Providers" ? ( + providerContent + ) : activeNav === "MCP" ? ( + + ) : activeNav === "Channels" ? ( + + ) : activeNav === "Schedules" ? ( + + ) : activeNav === "Account" ? ( + + ) : activeNav === "General" ? ( + + ) : ( +
+

+ {activeNav} settings coming soon. +

+
+ ); + + if (chrome === "content") { + return ( +
{content}
+ ); + } + return (
{/* Header bar */} @@ -440,88 +523,17 @@ export function SettingsView({ {/* Content area */} -
- {activeNav === "Providers" && selectedProvider ? ( - void loadProviderModels(selectedProvider.id)} - onOAuthLogin={ - isOAuthProvider(selectedProvider.id) - ? () => void runOAuthProviderLogin(selectedProvider.id) - : undefined - } - onUpdate={(updates) => - updateProvider(selectedProvider.id, updates) - } - provider={selectedProvider} - /> - ) : activeNav === "Providers" ? ( - addingProvider ? ( - provider.id)} - onBack={backToProviderList} - onSave={saveNewProvider} - /> - ) : providersLoading ? ( -
-

- Loading providers... -

-
- ) : providerCatalogError ? ( -
-

- Failed to load providers: {providerCatalogError} -

-
- ) : ( - - ) - ) : activeNav === "MCP" ? ( - - ) : activeNav === "Channels" ? ( - - ) : activeNav === "Schedules" ? ( - - ) : activeNav === "Customizations" ? ( - - ) : activeNav === "Account" ? ( - - ) : activeNav === "General" ? ( - - ) : ( -
-

- {activeNav} settings coming soon. -

-
- )} -
+
{content}
); } -function GeneralSettingsContent({ - onThemeChange, - theme, -}: { - onThemeChange: (theme: Theme) => void; - theme: Theme; -}) { +function GeneralSettingsContent() { + const [theme, setTheme] = useState(() => { + 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 ( - -
-
-

General

+ + +
+
+
+

+ Dark mode +

+

+ Keep the Hub interface in dark mode on this browser. +

+
+
-
-
-
-

Theme

-

- Use the light or dark Cline Hub interface. +

+
+

+ Auto update +

+

+ Automatically install CLI updates on startup. +

+ {autoUpdateError ? ( +

+ Failed to update auto update setting: {autoUpdateError}

-
-
- - -
+ ) : null}
-
-
-
-
-

Auto update

-

- Automatically install CLI updates on startup. + void updateAutoUpdateEnabled(checked)} + /> +

+
+
+

+ Telemetry +

+

+ Enable error and usage report to help us improve Cline. +

+ {telemetryError ? ( +

+ Failed to update telemetry setting: {telemetryError}

- {autoUpdateError ? ( -

- Failed to update auto update setting: {autoUpdateError} -

- ) : null} -
- - void updateAutoUpdateEnabled(checked) - } - /> + ) : null}
-
-
-
-
-

Telemetry

-

- Enable error and usage report to help us improve Cline. -

- {telemetryError ? ( -

- Failed to update telemetry setting: {telemetryError} -

- ) : null} -
- void updateTelemetryOptOut(checked)} - /> -
-
-
- + void updateTelemetryOptOut(!checked)} + /> +
+
+ ); } diff --git a/apps/cline-hub/src/webview/src/index.css b/apps/cline-hub/src/webview/src/index.css index 979235e05e..a8efd2c28f 100644 --- a/apps/cline-hub/src/webview/src/index.css +++ b/apps/cline-hub/src/webview/src/index.css @@ -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 */ diff --git a/apps/cline-hub/src/webview/src/lib/theme.ts b/apps/cline-hub/src/webview/src/lib/theme.ts new file mode 100644 index 0000000000..5651baf9d8 --- /dev/null +++ b/apps/cline-hub/src/webview/src/lib/theme.ts @@ -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); +} diff --git a/apps/cline-hub/src/webview/src/main.tsx b/apps/cline-hub/src/webview/src/main.tsx index 78b7aff731..c81588abda 100644 --- a/apps/cline-hub/src/webview/src/main.tsx +++ b/apps/cline-hub/src/webview/src/main.tsx @@ -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 with the VS Code theme. - * VS Code sets `data-vscode-theme-kind` on 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"], diff --git a/bun.lock b/bun.lock index bb30e65d77..ac81bc40d5 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],