Compare commits

...
Author SHA1 Message Date
abeatrix 83fa3219c9 feat: Cline Onboarding Webapp 2026-06-22 13:09:09 -07:00
abeatrix 733ff8c3ec fix(hub-ui): Use development mode conditionally for connector
Refactor connector CLI launch logic to conditionally apply Bun-specific
flags (`--conditions=development`) only when running under Bun or Node
runtimes with access to the source entrypoint. When running from a
compiled binary (e.g., packaged Cline app), use the execPath directly
without Bun flags.

- Extract `buildCliConnectCommand` function to encapsulate launcher
  and args resolution logic based on runtime detection
- Use `withResolvedClineBuildEnv` for environment variable setup
- Export `__test__` object to enable unit testing of internal logic
- Add tests covering Bun source entrypoint and compiled binary cases
2026-06-22 12:29:30 -07:00
Saoud Rizwan ee59f81706 chore(cli): release v3.0.29 2026-06-20 00:30:51 -07:00
Saoud Rizwan 92f6e28f13 fix(cli): hide costs for Cline free models (#11686)
* fix(cli): hide Cline free model costs

* fix(cli): harden free model cost lookup
2026-06-20 00:20:07 -07:00
Saoud Rizwan 27a78f0248 chore(sdk): release v0.0.51 2026-06-19 19:15:00 -07:00
Saoud Rizwan 2c4980f42a fix(sdk): resolve Cline Z.ai model metadata aliases (#11685)
* fix(sdk): resolve Cline Z.ai model metadata aliases

* fix(sdk): preserve Cline model alias overrides

* test(sdk): update Cline provider model list expectation
2026-06-19 19:05:47 -07:00
Saoud Rizwan 64c5e48edb Revert "fix(cli): resolve Cline model display names by just model name (#11668)" (#11684)
This reverts commit c497698beb.
2026-06-19 18:27:29 -07:00
Saoud Rizwan 3c23f80a94 fix(sdk): deflake run_commands timeout telemetry tests 2026-06-19 14:59:46 -07:00
Saoud Rizwan 4be362892f chore(cli): release v3.0.28 2026-06-19 14:52:50 -07:00
Saoud Rizwan cdff084652 chore(sdk): release v0.0.50 2026-06-19 14:11:25 -07:00
Robin NewhouseandSaoud Rizwan 299a4a9520 fix(sdk): harden parallel tool guidance (#11598)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 12:25:20 -07:00
Saoud Rizwan c497698beb fix(cli): resolve Cline model display names by just model name (#11668)
* fix(cli): resolve Cline model display names by slug

* fix(cli): type model display metadata

* test(cli): cover model display fallback paths
2026-06-19 12:21:57 -07:00
Ara 292133989b fix(sdk): cap assistant text in provider messages (#11478) 2026-06-19 11:41:04 -07:00
AraandSaoud Rizwan f5aa035a6a fix(sdk): default-on tool result truncation, tightened limits + budget accounting in MessageBuilder (#11475)
* fix(sdk): default-on tool result truncation, name fallback, tool_use budget accounting

- Truncate every tool result (MCP/custom tools included), not just an allowlist
- Resolve tool names from tool_result.name when the paired tool_use is gone
- Count tool_use.input strings toward the aggregate provider request budget
- Protect any binary carrier block ({type, data}) from truncation, not just images
- Don't let failSession cleanup errors mask the original turn error

* fix(sdk): tighten MessageBuilder limits with named options and env overrides

Folds #11474 into the default-on truncation branch and addresses review
feedback from both PRs:

- per-result tool cap drops to 8k chars; MessageBuilderOptions constructor
  with CLINE_MESSAGE_BUILDER_* env overrides for A/B testing
- env parsing is positive-only so '=0' cannot silently disable a limit
  (greptile on #11474)
- aggregate budget stays at 6MB: budget truncation rewrites mid-transcript
  bytes and breaks provider prefix caching, so it must stay a rare overflow
  valve rather than the steady state (johnwschoi on #11474)
- user file attachments get a dedicated 50k cap instead of inheriting the
  aggressive tool-result cap (codex on #11474)
- isBinaryContentLike restricted to known binary block types so textual
  {type, data} payloads can no longer dodge every cap (codex/greptile)
- tool_use.input strings become last-resort budget truncation candidates,
  making the aggregate budget reclaimable when oversized model-generated
  arguments carry the overflow (robinnewhouse/greptile)

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

* Revert "style(sdk): remove disallowed comment formatting"

This reverts commit 9d8b66726d.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 11:15:42 -07:00
Saoud Rizwan bbcae25542 fix(cli): refine marketplace primitive rows (#11659) 2026-06-19 10:57:37 -07:00
Tomás Barreiro 9b914912ac Rename Cline Pass to ClinePass (#11657) 2026-06-19 18:36:07 +02:00
Saoud Rizwan 8c369eeed9 docs(vscode): remove marketplace readme language links (#11658) 2026-06-18 18:09:02 -07:00
Saoud Rizwan 85223a07cf fix(cli): apply auto-approve toggles immediately (#11653) 2026-06-18 15:58:31 -07:00
John ChoiandBarreiroT eb2687677c feat(onboarding): add Cline Pass to the signup flow (#11609)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* feat(onboarding): add Cline Pass as optional user-type in signup flow

Surface Cline Pass as a recommended-but-optional onboarding choice gated
behind the ext-cline-pass feature flag, alongside Free / Frontier / BYOK.
When selected, signup provisions the cline-pass provider and ClinePass
model fields; price info is hidden since cost is covered by the
subscription. Falls back cleanly to the existing flow when the flag is
off. Adds unit tests for the new helpers.

* feat(onboarding): nudge + Cline Pass subscribe link in signup flow

Strengthen the Cline Pass onboarding option with a 'Recommended' copy
nudge (text + ordering, no badge/paid-default), and add an additive
subscribe affordance on the post-signup 'Almost there!' step that links
to {appBaseUrl}/dashboard/plan. Client-only: reuses existing appBaseUrl
from useClineAuth and the existing dashboard subscribe page; no backend
change. Shown only when the user selects Cline Pass; existing flow and
other user-types are unchanged.

* refactor(onboarding): redirect Cline Pass signup to subscription page

Replace the manual 'Get Cline Pass' callout with an automatic redirect:
after a Cline Pass user completes account creation, open the dashboard
subscription signup page (/onboarding/individual-plan) in the browser.
Keep the free option first and default-selected, with Cline Pass shown
second and labeled '(Recommended)'.

* chore(onboarding): tighten inline comments

* fix(onboarding): constrain Cline Pass selection and clear stale subscribe redirect

Addresses review findings:
- Never save a non-Cline-Pass model id under the cline-pass provider:
  Cline Pass selection no longer falls back to a free model when the
  Cline Pass list is empty, the generic model search is hidden for Cline
  Pass, finishOnboarding guards on a cline-pass/ id, and the CTA is
  disabled (with an empty state) when no Cline Pass model is available.
- Clear the pending subscription redirect when the user backs out, signs
  in, or navigates away from Cline Pass, and re-check userType in the
  redirect effect so a late auth update can't force the paid flow.

* fix(onboarding): add userType to handleFooterAction deps

The signup handler reads userType (to set the pending Cline Pass
subscribe flag); make that dependency explicit rather than relying on
the transitive finishOnboarding dependency.

* fix(onboarding): gate Cline Pass model data by feature flag

* fix(onboarding): address Greptile review on Cline Pass signup

- Log when the Cline Pass provider write is skipped due to an unexpected
  (non cline-pass/) model id, so the otherwise-silent no-op is observable.
- Open the subscription page for already-authenticated users by invoking
  the redirect helper directly after accountLoginClicked resolves, not
  only via the auth effect (which never re-runs when clineUser is
  unchanged).

* fix(onboarding): preserve ClinePass selection through login + one-word branding

- handleAuthCallback no longer forces the provider back to 'cline' on
  login when the user picked Cline Pass during onboarding; it preserves a
  'cline-pass' selection per mode. Only 'cline-pass' configs are affected
  (which require the ext-cline-pass flag), so all other logins are
  unchanged.
- Open the subscription page solely from the clineUser auth effect; the
  signup action no longer also calls it directly (prevents the login
  browser and subscribe page opening at once / dead-ends for already-
  authenticated users). Signup flow is otherwise untouched.
- Rename user-facing 'Cline Pass' to one-word 'ClinePass' (card titles,
  step title, empty state) and the model group label to 'clinepass'.

* chore(onboarding): normalize ClinePass branding in comments, trim verbose comments

* fix(merge): resolve ApiOptions redeclare + apply biome semicolon formatting

- Remove duplicate CLINE_PASS_FEATURE_FLAG const in ApiOptions.tsx (the
  shared import from constants/featureFlags supersedes main's local const).
- Apply biome format (semicolons) to onboarding/controller files so they
  match main's current style and pass Quality Checks.

* fix: restore clean no-semicolon formatting, keep only real ClinePass changes

The previous merge-recovery commit ran a local biome that incorrectly added
semicolons across entire files, bloating the diff by ~2000 LOC. Restore the
files to their clean pre-format state (matching main's asNeeded style) so the
diff reflects only the actual ClinePass onboarding changes (~420 LOC). Keeps
the handleAuthCallback provider-preservation fix and the ApiOptions duplicate
const removal.

* chore(onboarding): trim redundant inline comments

* chore: match main in refreshClineRecommendedModels (drop snake_case cline_pass handling)

* chore: trim featureFlags.ts comment

* fix(onboarding): open ClinePass subscribe page from App, not OnboardingView

handleAuthCallback marks the welcome view completed (unmounting OnboardingView)
before it pushes the auth-status update that sets clineUser. The redirect effect
lived in OnboardingView, so the pending-subscribe intent was lost on unmount and
the subscription page never opened for new ClinePass users.

Move the pending intent to a module-level store (clinePassSubscribe.ts) and run
the redirect from an effect in App, which outlives the onboarding unmount.

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-06-18 14:45:34 -07:00
Tomás Barreiro 6fe5acb2a4 Resolve feature flags using the user id on startup (#11652) 2026-06-18 23:24:42 +02:00
164 changed files with 12803 additions and 774 deletions
+16
View File
@@ -1,5 +1,21 @@
# Cline CLI Changelog
## 3.0.29
- Costs are now hidden for Cline free models
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
- Auto-approve toggles now apply immediately when changed
- Feature flags now resolve using your user ID on startup
- Fixed Cline model display names so they resolve by model name
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
## 3.0.27
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.27",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+33
View File
@@ -114,6 +114,7 @@ const telemetryMocks = vi.hoisted(() => ({
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
}));
function forcePromptModeInput() {
@@ -179,6 +180,8 @@ vi.mock("./utils/feature-flags", () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
@@ -252,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.getProviderSettings.mockReset();
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
providerSettingsMocks.saveProviderSettings.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -912,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: {
accountId: "acct-startup",
accessToken: "workos:token",
refreshToken: "refresh-token",
},
};
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
+7
View File
@@ -22,6 +22,7 @@ import {
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
@@ -919,6 +920,12 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
const persistedClineAccountId = providerSettingsManager
.getProviderSettings("cline")
?.auth?.accountId?.trim();
if (persistedClineAccountId) {
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
}
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
).resolves.toEqual({ approved: false, reason: "no" });
});
it("approves stale required-approval requests after auto-approve is enabled", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
controller.tuiToolApprover.current = async () => ({
approved: false,
reason: "stale prompt",
});
controller.setInteractiveAutoApprove(true);
await expect(
controller.requestToolApproval(makeRequest({ autoApprove: false })),
).resolves.toEqual({ approved: true });
});
it("denies approval-required requests when no TUI approver is available", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
expect(controller.autoApproveAllRef.current).toBe(true);
expect(config.defaultToolAutoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
});
});
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "../tool-policies";
export interface InteractiveRuntimeRefs {
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
const requestToolApproval = async (
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => {
if (request.policy?.autoApprove === true) {
if (autoApproveAllRef.current) {
return { approved: true };
}
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
if (request.policy?.autoApprove === true) {
return { approved: true };
}
if (refs.tuiToolApprover.current) {
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy: (toolName: string) =>
resolveInteractiveAutoApprovePolicy({
toolName,
baselinePolicies: baselineToolPolicies,
enabled: autoApproveAllRef.current,
}),
...refs,
};
}
@@ -152,7 +152,10 @@ function deferred<T>() {
function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
options: {
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
@@ -164,6 +167,8 @@ function makeRuntime(
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
resolveToolPolicy:
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
});
await runtime.ensureReady();
const startInput = manager.start.mock.calls[0]?.[0] as
| { config?: Config }
| undefined;
const beforeTool = startInput?.config?.hooks?.beforeTool;
expect(beforeTool).toBeTypeOf("function");
const result = await beforeTool?.({
snapshot: {
agentId: "agent-1",
conversationId: "conversation-1",
status: "running",
iteration: 1,
messages: [],
pendingToolCalls: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
},
tool: {
name: "echo",
description: "",
inputSchema: {},
execute: async () => "ok",
},
toolCall: {
type: "tool-call",
toolCallId: "call-1",
toolName: "echo",
input: { text: "original" },
},
input: { text: "original" },
});
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
expect(result).toEqual({
input: { text: "updated" },
policy: { autoApprove: true },
});
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
@@ -1,5 +1,6 @@
import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
function withInteractiveApprovalPolicyHook(
hooks: AgentHooks | undefined,
resolveToolPolicy: ToolPolicyResolver,
): AgentHooks {
return {
...hooks,
beforeTool: async (ctx) => {
const result = await hooks?.beforeTool?.(ctx);
if (result?.stop || result?.skip) {
return result;
}
const policy = resolveToolPolicy(ctx.toolCall.toolName);
return {
...result,
policy: {
...result?.policy,
autoApprove: policy.autoApprove,
},
};
},
};
}
export function createInteractiveSessionRuntime(input: {
config: Config;
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
requestToolApproval: (
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult>;
resolveToolPolicy: ToolPolicyResolver;
askQuestionRef: AskQuestionRef;
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
}
const hooks = withInteractiveApprovalPolicyHook(
runtimeHooks.hooks,
input.resolveToolPolicy,
);
return buildInteractiveSessionConfig({
config: input.config,
chatCommandState: input.chatCommandState,
runtimeHooks,
runtimeHooks: { hooks },
onTeamEvent: input.onTeamEvent,
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
});
+131 -1
View File
@@ -27,6 +27,16 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const sessionEventsMocks = vi.hoisted(() => ({
listener: undefined as ((event: unknown) => void) | undefined,
subscribeToAgentEvents: vi.fn(
(_: unknown, listener: (event: unknown) => void) => {
sessionEventsMocks.listener = listener;
return () => {};
},
),
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
@@ -88,7 +98,7 @@ vi.mock("./prompt", () => ({
}));
vi.mock("./session-events", () => ({
subscribeToAgentEvents: vi.fn(() => () => {}),
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
}));
describe("runAgent", () => {
@@ -112,6 +122,9 @@ describe("runAgent", () => {
outputMocks.writeln.mockReset();
outputMocks.emitJsonLine.mockReset();
outputMocks.setActiveCliSession.mockReset();
sessionEventsMocks.listener = undefined;
sessionEventsMocks.subscribeToAgentEvents.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => {
@@ -938,4 +951,121 @@ describe("runAgent", () => {
expect.stringContaining("est. cost"),
);
});
it("zeros Cline free model costs in JSON results and agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: {
session_id: "session-1",
},
result: {
text: "completed text",
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "completed",
model: {
id: "deepseek/deepseek-v4-flash",
provider: "cline",
info: {},
},
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
aggregateUsage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
});
const { runAgent } = await import("./run-agent");
const { handleEvent } = await import("../utils/events");
await expect(
runAgent("test prompt", {
baseUrl: "https://cline.test/api/v1",
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: {
maxConsecutiveMistakes: 3,
},
logger: undefined,
mode: "yolo",
modelId: "deepseek/deepseek-v4-flash",
outputMode: "json",
providerId: "cline",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
const runResult = outputMocks.emitJsonLine.mock.calls.find(
([, payload]) =>
(payload as { type?: string } | undefined)?.type === "run_result",
)?.[1] as
| {
usage?: { totalCost?: number };
aggregateUsage?: { totalCost?: number };
}
| undefined;
expect(runResult?.usage?.totalCost).toBe(0);
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
sessionEventsMocks.listener?.({
type: "usage",
inputTokens: 1,
outputTokens: 1,
cost: 0.25,
totalCost: 0.25,
});
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: "usage",
cost: 0,
totalCost: 0,
}),
expect.any(Object),
);
});
});
+16 -3
View File
@@ -18,6 +18,11 @@ import {
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import { createRuntimeHooks } from "../utils/hooks";
import {
c,
@@ -184,8 +189,10 @@ export async function runAgent(
let reasoningChunkCount = 0;
let redactedReasoningChunkCount = 0;
const displayedErrorMessages = new Set<string>();
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
const onAgentEvent = (event: AgentEvent): void => {
const onAgentEvent = (rawEvent: AgentEvent): void => {
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
if (event.type === "content_start" && event.contentType === "reasoning") {
reasoningChunkCount += 1;
if (event.redacted) {
@@ -339,8 +346,14 @@ export async function runAgent(
const usageSummary = await sessionManager.getAccumulatedUsage(
started.sessionId,
);
const aggregateUsage = usageSummary?.aggregateUsage;
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
const aggregateUsage = zeroCliUsageCost(
usageSummary?.aggregateUsage,
shouldZeroCost,
);
const usage = zeroCliUsageCost(
aggregateUsage ?? usageSummary?.usage ?? result.usage,
shouldZeroCost,
);
if (config.outputMode === "json") {
emitJsonLine("stdout", {
+20 -4
View File
@@ -24,6 +24,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import {
prepareTerminalForPostTuiOutput,
writeErr,
@@ -121,6 +126,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -152,6 +158,7 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -160,11 +167,12 @@ export async function runInteractive(
resumeSessionId,
chatCommandState,
requestToolApproval,
resolveToolPolicy,
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
onAgentEvent: (event) => {
uiEvents.emit("agent", event);
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
},
onTeamEvent: (event) => {
uiEvents.emit("team", event);
@@ -430,6 +438,7 @@ export async function runInteractive(
},
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
let zeroTurnCost = false;
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -476,6 +485,8 @@ export async function runInteractive(
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
zeroCurrentTurnCost = zeroTurnCost;
const {
prompt: userInput,
userImages,
@@ -517,8 +528,9 @@ export async function runInteractive(
}
if (result.finishReason !== "completed") {
if (result.finishReason === "aborted" || isAbortInProgress()) {
const usage = await sessionRuntime.getAccumulatedUsage(
result.usage,
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
@@ -533,7 +545,10 @@ export async function runInteractive(
errorText || `Turn finished with ${result.finishReason}`,
);
}
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
currentContextSize: getCurrentContextSize(result.messages),
@@ -557,6 +572,7 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
+38 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "./tool-policies";
describe("tool policy helpers", () => {
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
});
});
it("restores the baseline policies when toggled back on", () => {
it("forces all baseline policies to auto-approve when toggled back on", () => {
const baseline = {
"*": { autoApprove: true },
"*": { autoApprove: false },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: false, enabled: true },
};
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
enabled: true,
});
expect(target).toEqual(baseline);
expect(target).toEqual({
"*": { autoApprove: true },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: true, enabled: true },
});
});
it("resolves live per-tool policies from the interactive auto-approve state", () => {
const baseline = {
"*": { autoApprove: false },
read_files: { enabled: true },
editor: { autoApprove: false, enabled: true },
};
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "editor",
baselinePolicies: baseline,
enabled: true,
}),
).toEqual({ autoApprove: true, enabled: true });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "run_commands",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: false });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "read_files",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: true, enabled: true });
});
});
+35 -7
View File
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
);
}
export function resolveInteractiveAutoApprovePolicy(input: {
toolName: string;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): ToolPolicy {
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
const baselinePolicy = {
...(input.baselinePolicies["*"] ?? {}),
...toolPolicy,
};
return {
...baselinePolicy,
autoApprove: input.enabled
? true
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
? (toolPolicy.autoApprove ?? true)
: false,
};
}
export function applyInteractiveAutoApproveOverride(input: {
targetPolicies: Record<string, ToolPolicy>;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): void {
const nextPolicies: Record<string, ToolPolicy> = input.enabled
? cloneToolPolicies(input.baselinePolicies)
? Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: true,
},
]),
)
: Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
? (policy.autoApprove ?? true)
: false,
autoApprove: resolveInteractiveAutoApprovePolicy({
toolName: name,
baselinePolicies: input.baselinePolicies,
enabled: false,
}).autoApprove,
},
]),
);
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
}
const globalPolicy = clonePolicy(nextPolicies["*"]);
globalPolicy.autoApprove = input.enabled
? (input.baselinePolicies["*"]?.autoApprove ?? true)
: false;
globalPolicy.autoApprove = input.enabled;
nextPolicies["*"] = globalPolicy;
for (const key of Object.keys(input.targetPolicies)) {
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
const [uiMode, setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
const [autoApproveAll, _setAutoApproveAll] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
}, []);
const toggleAutoApprove = useCallback(() => {
_setAutoApproveAll((prev) => {
const next = !prev;
onAutoApproveChange(next);
return next;
});
const next = !autoApproveAllRef.current;
autoApproveAllRef.current = next;
onAutoApproveChange(next);
_setAutoApproveAll(next);
}, [onAutoApproveChange]);
const setCompactionMode = useCallback(
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
startClineDeviceAuth: vi.fn(),
completeClineDeviceAuth: vi.fn(),
saveLocalProviderOAuthCredentials: vi.fn(),
identifyFeatureFlagsAccount: vi.fn(async () => {}),
openMock: vi.fn(() => Promise.resolve()),
}));
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
vi.mock("open", () => ({ default: hoisted.openMock }));
vi.mock("../../../utils/feature-flags", () => ({
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
}));
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
// Minimal stand-in for a telemetry service. The auth helpers must forward this
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
hoisted.startClineDeviceAuth.mockReset();
hoisted.completeClineDeviceAuth.mockReset();
hoisted.saveLocalProviderOAuthCredentials.mockReset();
hoisted.identifyFeatureFlagsAccount.mockReset();
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
hoisted.openMock.mockReset();
hoisted.openMock.mockResolvedValue(undefined);
});
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
// Identity, not deep-equal — we are validating the exact reference flows
// through so opt-out / common metadata stays consistent.
expect(telemetryArg).toBe(fakeTelemetry);
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
});
it("does not pass telemetry when none is provided (back-compat)", () => {
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
access: "a",
refresh: "r",
expires: 0,
accountId: "acct-1",
email: "user@example.com",
});
runDeviceCodeAuthFlow({
@@ -146,6 +156,10 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
id: "acct-1",
email: "user@example.com",
});
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
+17
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import open from "open";
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
export type OnboardingOAuthProviderId = string;
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
return isOAuthProvider(providerId);
}
function isClineAccountOAuthProvider(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export function runOAuthAuthFlow(input: {
providerId: OnboardingOAuthProviderId;
providerSettingsManager: ProviderSettingsManager;
@@ -56,6 +61,12 @@ export function runOAuthAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -118,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
+12 -5
View File
@@ -87,22 +87,29 @@ export async function disposeCliFeatureFlagsService(): Promise<void> {
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
export function setCliFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): void {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
setCliFeatureFlagsAccountContext(account);
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
+162
View File
@@ -0,0 +1,162 @@
import type { AgentEvent } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "./free-model-cost";
afterEach(() => {
clearClineFreeModelCostCache();
vi.unstubAllGlobals();
});
describe("shouldZeroClineFreeModelCost", () => {
it("uses the Cline free model list", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://cline.test/api/v1/ai/cline/recommended-models",
);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "openrouter",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "acme/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("retries after a failed free model list fetch", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("zeroCliUsageCost", () => {
it("zeros total cost while preserving token usage", () => {
expect(
zeroCliUsageCost(
{
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
true,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
});
});
});
describe("zeroCliAgentEventCost", () => {
it("zeros usage event cost fields", () => {
const event = {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cost: 0.001,
totalCost: 0.001,
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
cost: 0,
totalCost: 0,
});
});
it("zeros done event usage cost", () => {
const event = {
type: "done",
reason: "completed",
text: "ok",
iterations: 1,
usage: {
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
usage: { totalCost: 0 },
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { AgentEvent } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
const freeModelIdsByBaseUrl = new Map<
string,
Promise<readonly string[] | undefined>
>();
function normalizeModelId(modelId: string | undefined): string {
return modelId?.trim().toLowerCase() ?? "";
}
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
const selected = normalizeModelId(selectedModelId);
const free = normalizeModelId(freeModelId);
if (!selected || !free) return false;
return selected === free;
}
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
? normalizedBaseUrl.slice(0, -"/api/v1".length)
: normalizedBaseUrl;
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
}
async function fetchClineFreeModelIds(
baseUrl: string,
): Promise<readonly string[] | undefined> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
signal: controller.signal,
});
if (!response.ok) return undefined;
const json = (await response.json()) as { free?: unknown };
return Array.isArray(json.free)
? json.free
.map((model) =>
model && typeof model === "object"
? (model as Record<string, unknown>).id
: undefined,
)
.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
const cacheKey = baseUrl.trim();
let cached = freeModelIdsByBaseUrl.get(cacheKey);
if (!cached) {
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
return ids;
});
freeModelIdsByBaseUrl.set(cacheKey, cached);
}
return cached.then((ids) => ids ?? []);
}
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
const baseUrl =
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const freeModelIds = await getClineFreeModelIds(baseUrl);
return freeModelIds.some((freeModelId) =>
modelIdsMatch(modelId, freeModelId),
);
}
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
usage: T,
shouldZeroCost: boolean,
): T {
if (
!shouldZeroCost ||
!usage ||
typeof usage.totalCost !== "number" ||
usage.totalCost === 0
) {
return usage;
}
return { ...usage, totalCost: 0 } as T;
}
export function zeroCliAgentEventCost(
event: AgentEvent,
shouldZeroCost: boolean,
): AgentEvent {
if (!shouldZeroCost) return event;
if (event.type === "done" && event.usage) {
return {
...event,
usage: zeroCliUsageCost(event.usage, true),
};
}
if (event.type !== "usage") return event;
const next = { ...event } as Record<string, unknown>;
if (typeof next.cost === "number") next.cost = 0;
if (typeof next.totalCost === "number") next.totalCost = 0;
return next as unknown as AgentEvent;
}
export function clearClineFreeModelCostCache(): void {
freeModelIdsByBaseUrl.clear();
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
describe("connector launch command", () => {
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Users/test/.bun/bin/bun",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("uses compiled CLI subcommands without Bun flags", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Applications/Cline/bin/cline",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Applications/Cline/bin/cline",
childArgs: ["connect", "telegram", "--bot-token", "token"],
});
});
it("strips terminal color codes from connector command failures", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
"connector start failed",
),
).toBe("unknown option '--conditions=development'");
});
it("turns Telegram unauthorized responses into a token validation message", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
"connector start failed",
),
).toBe(
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
});
+86 -23
View File
@@ -1,5 +1,8 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
@@ -13,6 +16,68 @@ import type {
import { cliIndexPath, workspaceRoot } from "./deps";
import { asRecord, asString } from "./utils";
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath = options.cliPath ?? cliIndexPath;
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
stdout: string;
stderr: string;
}> {
const launcher = (process.versions as Record<string, string | undefined>).bun
? process.execPath
: "bun";
const child = spawn(
launcher,
["--conditions=development", cliIndexPath, "connect", ...args],
{
cwd: workspaceRoot,
env: {
...process.env,
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
const { launcher, childArgs } = buildCliConnectCommand(args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
normalizeConnectorError,
};
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
@@ -3,6 +3,7 @@ import {
Puzzle,
Search,
Server,
Star,
Trash2,
Zap,
} from "lucide-react";
@@ -26,6 +27,7 @@ import {
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
@@ -114,6 +116,13 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function compareFeaturedEntries(
left: MarketplaceEntry,
right: MarketplaceEntry,
): number {
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
}
function normalizeMatchValue(value: string): string {
return value.trim().toLowerCase();
}
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
showFeatured = true,
showTags = true,
sourceLabel,
tagLabels,
}: {
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
showFeatured?: boolean;
showTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
@@ -299,15 +312,63 @@ function MarketplaceEntryCard({
: installed
? "Uninstall"
: "Install";
const statusMessage = inlineMessage ? (
<output
className={cn(
"text-xs",
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-xs text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null;
const actionButton = (
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
size="sm"
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
);
const content = (
<>
{!installed ? (
<div
className="absolute top-4 right-4"
data-marketplace-entry-interactive
>
{actionButton}
</div>
) : null}
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
!installed && "pr-24",
)}
>
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
{showFeatured && entry.featured ? (
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
<Star className="fill-current" />
Featured
</Badge>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{sourceLabel ? (
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
) : null}
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
<div className="grid min-w-0 flex-1 gap-2">
{showTags && entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
) : null}
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{statusMessage}
</div>
) : null}
{installed ? (
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
{actionButton}
</div>
) : null}
</div>
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
@@ -377,36 +449,6 @@ function MarketplaceEntryCard({
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{inlineMessage ? (
<output
className={
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded && hasExpandableDetails ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -487,6 +529,7 @@ function MarketplaceSection({
emptyMessage,
entries,
expandedEntryKey,
headerContent,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
@@ -494,6 +537,8 @@ function MarketplaceSection({
onInstall,
onToggleExpanded,
onUninstall,
showFeaturedBadges = true,
showEntryTags = true,
sourceLabel,
tagLabels,
title,
@@ -502,6 +547,7 @@ function MarketplaceSection({
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
headerContent?: ReactNode;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
@@ -509,6 +555,8 @@ function MarketplaceSection({
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
showFeaturedBadges?: boolean;
showEntryTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
title: string;
@@ -520,6 +568,7 @@ function MarketplaceSection({
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{headerContent}
{totalCount > 0 ? (
<div className="grid gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
@@ -537,6 +586,8 @@ function MarketplaceSection({
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
showFeatured={showFeaturedBadges}
showTags={showEntryTags}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
@@ -626,19 +677,48 @@ export function MarketplaceView({
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
() =>
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
compareFeaturedEntries,
),
[catalog?.entries, primitive],
);
const queryFilteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
return (
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
);
});
}, [primitiveEntries, query, tagLabels]);
const installedEntries = useMemo(
() =>
queryFilteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const marketplaceEntriesBeforeTag = useMemo(
() =>
queryFilteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of primitiveEntries) {
for (const entry of marketplaceEntriesBeforeTag) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
}, [marketplaceEntriesBeforeTag]);
const primitiveTags = useMemo(
() =>
@@ -646,26 +726,20 @@ export function MarketplaceView({
[catalog?.tags, tagCounts],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const catalogEntries = useMemo(
() =>
marketplaceEntriesBeforeTag.filter(
(entry) => !selectedTag || entry.tags.includes(selectedTag),
),
[marketplaceEntriesBeforeTag, selectedTag],
);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of filteredEntries) {
for (const entry of installedEntries) {
const key = entryKey(entry);
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
if (!entryMatchesLocalItem(entry, item)) {
continue;
}
const items = matched.get(key) ?? [];
@@ -674,49 +748,78 @@ export function MarketplaceView({
}
}
return matched;
}, [filteredEntries, installedEntryKeys, installedItems]);
}, [installedEntries, installedItems]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
const matchedLocalItemKeys = useMemo(() => {
const matched = new Set<string>();
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
);
for (const item of installedItems ?? []) {
if (
installedMarketplaceEntries.some((entry) =>
entryMatchesLocalItem(entry, item),
)
) {
matched.add(item.key);
}
}
return matched;
}, [installedEntryKeys, installedItems, primitiveEntries]);
const localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
const localOnlyInstalledItems = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return (installedItems ?? []).filter((item) => {
if (matchedLocalItemKeys.has(item.key)) {
return false;
}
return (
normalizedQuery.length === 0 ||
item.matchValues
.map(normalizeMatchValue)
.some((value) => value.includes(normalizedQuery))
);
});
}, [installedItems, matchedLocalItemKeys, query]);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{catalogEntries.length}
</span>
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
{selectedTag ? (
<Button
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
) : null;
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
@@ -863,43 +966,7 @@ export function MarketplaceView({
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
@@ -914,6 +981,8 @@ export function MarketplaceView({
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
showFeaturedBadges={false}
showEntryTags={false}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
@@ -924,6 +993,7 @@ export function MarketplaceView({
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
headerContent={marketplaceTagFilters}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
@@ -39,10 +39,10 @@ export type CustomizationSection =
const sectionDescriptions: Record<CustomizationSection, string> = {
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.",
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
Skills: "Manage installed skills and add new skills from the marketplace.",
Agents: "Review configured agents discovered from local settings.",
Plugins: "Manage installed plugins and add new plugins from the catalog.",
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
Tools: "Inspect built-in tools and tools contributed by plugins.",
};
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
@@ -152,6 +153,10 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
-4
View File
@@ -1,7 +1,3 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
+12 -6
View File
@@ -513,8 +513,6 @@ export class Controller {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
const clineProvider: ApiProvider = "cline"
// Get current settings to determine how to update providers
const planActSeparateModelsSetting = this.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
@@ -523,19 +521,27 @@ export class Controller {
// Get current API configuration from cache
const currentApiConfiguration = this.stateManager.getApiConfiguration()
// On login we route the user to the managed "cline" provider, but preserve a
// "cline-pass" selection made during onboarding (otherwise it would be clobbered).
// A "cline-pass" provider can only be set when the ext-cline-pass flag is on, so
// non-ClinePass logins are unaffected.
const planProvider: ApiProvider =
currentApiConfiguration.planModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const actProvider: ApiProvider = currentApiConfiguration.actModeApiProvider === "cline-pass" ? "cline-pass" : "cline"
const updatedConfig = { ...currentApiConfiguration }
if (planActSeparateModelsSetting) {
// Only update the current mode's provider
if (currentMode === "plan") {
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
} else {
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = actProvider
}
} else {
// Update both modes to keep them in sync
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = clineProvider
updatedConfig.planModeApiProvider = planProvider
updatedConfig.actModeApiProvider = actProvider
}
// Update the API configuration through cache service
@@ -25,7 +25,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
} as unknown as Controller;
}
it("does nothing when Cline Pass is not selected", async () => {
it("does nothing when ClinePass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
@@ -34,7 +34,7 @@ describe("clearOrganizationForClinePassProviderSelection", () => {
assert.strictEqual(switchAccount.callCount, 0);
});
it("switches to the personal account when Cline Pass is selected", async () => {
it("switches to the personal account when ClinePass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
@@ -48,7 +48,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -79,7 +79,7 @@ describe("refreshClineRecommendedModels", () => {
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -114,7 +114,7 @@ describe("refreshClineRecommendedModels", () => {
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
description: "Remote ClinePass",
tags: ["CLINE_PASS"],
},
],
@@ -5,7 +5,7 @@ import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* Cline Pass always uses the user's personal Cline account balance.
* ClinePass always uses the user's personal Cline account balance.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
@@ -24,6 +24,6 @@ export async function clearOrganizationForClinePassProviderSelection(
try {
await controller.accountService.switchAccount(null)
} catch (error) {
Logger.debug("Failed to switch Cline Pass to personal account", { error })
Logger.debug("Failed to switch ClinePass to personal account", { error })
}
}
@@ -60,7 +60,7 @@ describe("fetchRemoteConfig", () => {
sandbox.restore();
});
it("clears remote config and skips discovery when Cline Pass is selected", async () => {
it("clears remote config and skips discovery when ClinePass is selected", async () => {
const controller = createController({
stateManager: {
getApiConfiguration: sandbox.stub().returns({
@@ -6,7 +6,7 @@
},
{
"value": "cline-pass",
"label": "Cline Pass"
"label": "ClinePass"
},
{
"value": "openai-codex",
@@ -15,7 +15,7 @@ export enum FeatureFlag {
// Rollout flag for Cline provider model sourcing:
// off => OpenRouter model list, on => Cline endpoint model list.
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
// Enables Cline Pass provider/model list exposure.
// Enables ClinePass provider/model list exposure.
CLINE_PASS = "ext-cline-pass",
// Use the websocket mode for OpenAI native Responses API format
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
@@ -23,7 +23,7 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
it("uses separate model keys for Cline Pass", () => {
it("uses separate model keys for ClinePass", () => {
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
})
+9
View File
@@ -4,6 +4,7 @@ import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import { openClinePassSubscriptionIfPending } from "./components/onboarding/clinePassSubscribe"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WorktreesView from "./components/worktrees/WorktreesView"
@@ -56,6 +57,14 @@ const AppContent = () => {
showUpdateAnnouncementModal()
}, [didHydrateState, showWelcome, shouldShowAnnouncement, showAnnouncement, showUpdateAnnouncementModal])
// Open the ClinePass subscription page once auth completes. Lives here (not in OnboardingView)
// because handleAuthCallback unmounts onboarding before the clineUser update arrives.
useEffect(() => {
if (clineUser?.uid) {
openClinePassSubscriptionIfPending(clineUser.appBaseUrl)
}
}, [clineUser?.uid, clineUser?.appBaseUrl])
if (!didHydrateState) {
return null
}
@@ -1,4 +1,4 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import type { OnboardingModel, OnboardingModelGroup, OpenRouterModelInfo } from "@shared/proto/index.cline"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
@@ -7,9 +7,12 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import { setPendingClinePassSubscribe } from "./clinePassSubscribe"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import WelcomeView from "../welcome/WelcomeView"
@@ -20,11 +23,11 @@ import {
getSpeedLabel,
type OnboardingModelsByGroup,
} from "./data-models"
import { NEW_USER_TYPE, STEP_CONFIG, USER_TYPE_SELECTIONS } from "./data-steps"
import { getUserTypeSelections, NEW_USER_TYPE, STEP_CONFIG } from "./data-steps"
import { useOnboardingModels } from "./useOnboardingModels"
type ModelSelectionProps = {
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER | NEW_USER_TYPE.CLINE_PASS
selectedModelId: string
onSelectModel: (modelId: string) => void
onboardingModels: OnboardingModelsByGroup
@@ -33,6 +36,13 @@ type ModelSelectionProps = {
setSearchTerm: (term: string) => void
}
function getModelGroupKey(userType: ModelSelectionProps["userType"]): keyof OnboardingModelsByGroup {
if (userType === NEW_USER_TYPE.CLINE_PASS) {
return "clinePass"
}
return userType === NEW_USER_TYPE.FREE ? "free" : "power"
}
const ModelSelection = ({
userType,
selectedModelId,
@@ -42,7 +52,10 @@ const ModelSelection = ({
setSearchTerm,
onboardingModels,
}: ModelSelectionProps) => {
const modelGroups = onboardingModels[userType === NEW_USER_TYPE.FREE ? "free" : "power"]
const isClinePass = userType === NEW_USER_TYPE.CLINE_PASS
const modelGroups = onboardingModels[getModelGroupKey(userType)]
// ClinePass costs are covered by the subscription, so prices are hidden.
const hidePrice = isClinePass
const searchedModels = useMemo(() => {
if (!models || !searchTerm) {
@@ -73,7 +86,7 @@ const ModelSelection = ({
<Badge className="capitalize" variant="info">
{model.badge}
</Badge>
) : model.info ? (
) : !hidePrice && model.info ? (
<Badge>{getPriceRange(model.info)}</Badge>
) : null}
</ItemTitle>
@@ -99,7 +112,7 @@ const ModelSelection = ({
<span>Context: </span>
<span className="text-foreground/70">{(model?.info.contextWindow || 0) / 1000}k</span>
</div>
<Badge>{getPriceRange(model.info)}</Badge>
{!hidePrice && <Badge>{getPriceRange(model.info)}</Badge>}
</div>
)}
</div>
@@ -109,6 +122,16 @@ const ModelSelection = ({
)
}
// No curated ClinePass models available: show an empty state rather than other models.
if (isClinePass && modelGroups.length === 0) {
return (
<div className="flex w-full max-w-lg flex-col items-center justify-center my-8 px-2 text-center">
<p className="text-foreground text-sm m-0">No ClinePass models are available right now.</p>
<p className="text-foreground/70 text-sm mt-1">Please choose another option or try again later.</p>
</div>
)
}
return (
<div className="flex flex-col w-full items-center px-2">
<div className="flex w-full max-w-lg flex-col gap-6 my-4">
@@ -122,67 +145,68 @@ const ModelSelection = ({
))}
</div>
{/* SEARCH MODEL */}
<div className="flex w-full max-w-lg flex-col gap-6 my-4 border-t border-muted-foreground">
<div className="flex flex-col gap-3 mt-6" key="search-results">
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">other options</h4>
<Input
autoFocus={false}
className="focus-visible:border-button-background"
onChange={(e) => {
if (!e.target?.value) {
onSelectModel("")
}
setSearchTerm(e.target.value)
}}
onClick={() => onSelectModel("")}
placeholder="Search model..."
type="search"
value={searchTerm}
/>
<div className="w-full flex flex-col gap-3">
{searchTerm &&
searchedModels.map(([id, info]) => {
const isSelected = selectedModelId === id
// Convert ModelInfo to OpenRouterModelInfo for OnboardingModel
const modelInfo: OpenRouterModelInfo = {
name: info.name,
maxTokens: info.maxTokens,
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
cacheReadsPrice: info.cacheReadsPrice,
description: info.description,
supportsGlobalEndpoint: info.supportsGlobalEndpoint,
thinkingConfig: info.thinkingConfig
? {
maxBudget: info.thinkingConfig.maxBudget,
outputPrice: info.thinkingConfig.outputPrice,
outputPriceTiers: info.thinkingConfig.outputPriceTiers || [],
}
: undefined,
tiers: info.tiers || [],
{/* SEARCH MODEL — hidden for ClinePass, whose selection is constrained to the curated list. */}
{!isClinePass && (
<div className="flex w-full max-w-lg flex-col gap-6 my-4 border-t border-muted-foreground">
<div className="flex flex-col gap-3 mt-6" key="search-results">
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">other options</h4>
<Input
autoFocus={false}
className="focus-visible:border-button-background"
onChange={(e) => {
if (!e.target?.value) {
onSelectModel("")
}
const onboardingModel: OnboardingModel = {
id,
name: info.name || id,
info: modelInfo,
score: 0,
latency: 0,
badge: "",
group: "",
}
return <ModelItem id={id} isSelected={isSelected} key={id} model={onboardingModel} />
})}
{searchTerm.length > 0 && searchedModels.length === 0 && (
<p className="px-1 mt-1 text-sm text-foreground/70">No result found for "{searchTerm}"</p>
)}
setSearchTerm(e.target.value)
}}
onClick={() => onSelectModel("")}
placeholder="Search model..."
type="search"
value={searchTerm}
/>
<div className="w-full flex flex-col gap-3">
{searchTerm &&
searchedModels.map(([id, info]) => {
const isSelected = selectedModelId === id
const modelInfo: OpenRouterModelInfo = {
name: info.name,
maxTokens: info.maxTokens,
contextWindow: info.contextWindow,
supportsImages: info.supportsImages,
supportsPromptCache: info.supportsPromptCache,
inputPrice: info.inputPrice,
outputPrice: info.outputPrice,
cacheWritesPrice: info.cacheWritesPrice,
cacheReadsPrice: info.cacheReadsPrice,
description: info.description,
supportsGlobalEndpoint: info.supportsGlobalEndpoint,
thinkingConfig: info.thinkingConfig
? {
maxBudget: info.thinkingConfig.maxBudget,
outputPrice: info.thinkingConfig.outputPrice,
outputPriceTiers: info.thinkingConfig.outputPriceTiers || [],
}
: undefined,
tiers: info.tiers || [],
}
const onboardingModel: OnboardingModel = {
id,
name: info.name || id,
info: modelInfo,
score: 0,
latency: 0,
badge: "",
group: "",
}
return <ModelItem id={id} isSelected={isSelected} key={id} model={onboardingModel} />
})}
{searchTerm.length > 0 && searchedModels.length === 0 && (
<p className="px-1 mt-1 text-sm text-foreground/70">No result found for "{searchTerm}"</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -190,12 +214,13 @@ const ModelSelection = ({
type UserTypeSelectionProps = {
userType: NEW_USER_TYPE | undefined
onSelectUserType: (type: NEW_USER_TYPE) => void
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const UserTypeSelectionStep = ({ userType, onSelectUserType }: UserTypeSelectionProps) => (
const UserTypeSelectionStep = ({ userType, onSelectUserType, userTypeSelections }: UserTypeSelectionProps) => (
<div className="flex flex-col w-full items-center">
<div className="flex w-full max-w-lg flex-col gap-3 my-2">
{USER_TYPE_SELECTIONS.map((option) => {
{userTypeSelections.map((option) => {
const isSelected = userType === option.type
return (
@@ -229,6 +254,7 @@ type OnboardingStepContentProps = {
setSearchTerm: (term: string) => void
models?: Record<string, ModelInfo>
onboardingModels: OnboardingModelsByGroup
userTypeSelections: ReturnType<typeof getUserTypeSelections>
}
const OnboardingStepContent = ({
@@ -241,14 +267,21 @@ const OnboardingStepContent = ({
setSearchTerm,
models,
onboardingModels,
userTypeSelections,
}: OnboardingStepContentProps) => {
if (step === 0) {
return <UserTypeSelectionStep onSelectUserType={onSelectUserType} userType={userType} />
return (
<UserTypeSelectionStep
onSelectUserType={onSelectUserType}
userType={userType}
userTypeSelections={userTypeSelections}
/>
)
}
if (step === 2) {
return null
}
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER) {
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER || userType === NEW_USER_TYPE.CLINE_PASS) {
return (
<ModelSelection
models={models}
@@ -268,6 +301,8 @@ const OnboardingStepContent = ({
const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: OnboardingModelGroup }) => {
const { handleFieldsChange } = useApiConfigurationHandlers()
const { openRouterModels, hideSettings, hideAccount, setShowWelcome } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const userTypeSelections = useMemo(() => getUserTypeSelections(isClinePassEnabled), [isClinePassEnabled])
const [stepNumber, setStepNumber] = useState(0)
const [isActionLoading, setIsActionLoading] = useState(false)
@@ -277,23 +312,30 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const [searchTerm, setSearchTerm] = useState("")
const models = useMemo(() => getClineUIOnboardingGroups(onboardingModels), [onboardingModels])
// ClinePass model IDs (e.g. "cline-pass/glm-5.1") aren't keyed in openRouterModels,
// so resolve their info via the slug-based lookup used by ClinePassProvider.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
useEffect(() => {
setSearchTerm("")
const userGroup = userType === NEW_USER_TYPE.POWER ? NEW_USER_TYPE.POWER : NEW_USER_TYPE.FREE
const modelGroup = models[userGroup][0]
const userGroupInitModel = modelGroup.models[0]
setSelectedModelId(userGroupInitModel.id)
const groupKey = userType === NEW_USER_TYPE.CLINE_PASS ? "clinePass" : userType === NEW_USER_TYPE.POWER ? "power" : "free"
// ClinePass must stay within its curated list (never fall back to a free/OpenRouter model
// under the cline-pass provider). Free/Frontier fall back to free if their group is empty.
const modelGroup = userType === NEW_USER_TYPE.CLINE_PASS ? models[groupKey][0] : (models[groupKey][0] ?? models.free[0])
const userGroupInitModel = modelGroup?.models[0]
setSelectedModelId(userGroupInitModel?.id ?? "")
}, [userType, models])
const onUserTypeClick = useCallback((userType: NEW_USER_TYPE) => {
setUserType(userType)
const action =
userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
userType === NEW_USER_TYPE.CLINE_PASS
? "cline_pass_user_selected"
: userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
// User selection is available in step 0 only
StateServiceClient.captureOnboardingProgress({ step: 0, action })
}, [])
@@ -307,28 +349,49 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
const finishOnboarding = useCallback(
async (updateModelId: boolean, step: number) => {
const modelSelected = (updateModelId && selectedModelId) || undefined
// Guard: never save a non-ClinePass model id under the cline-pass provider.
const isClinePassModel = selectedModelId.startsWith("cline-pass/")
if (modelSelected) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
if (userType === NEW_USER_TYPE.CLINE_PASS && isClinePassModel) {
const clinePassModelInfo = resolveClinePassModelInfo(selectedModelId, openRouterModelsByName)
await handleFieldsChange({
planModeClinePassModelId: selectedModelId,
actModeClinePassModelId: selectedModelId,
planModeClinePassModelInfo: clinePassModelInfo,
actModeClinePassModelInfo: clinePassModelInfo,
planModeApiProvider: "cline-pass",
actModeApiProvider: "cline-pass",
})
} else if (userType !== NEW_USER_TYPE.CLINE_PASS) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
} else {
// ClinePass selected but the id isn't a cline-pass/ model: skip the write
// (avoids a bad provider config) and log so the no-op is observable.
console.error(`Skipped ClinePass provider setup: unexpected model id "${selectedModelId}"`)
}
}
hideAccount()
hideSettings()
const action = "onboarding_completed"
StateServiceClient.captureOnboardingProgress({ step, modelSelected, action, completed: true })
},
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels],
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels, openRouterModelsByName, userType],
)
const handleFooterAction = useCallback(
async (action: "signin" | "next" | "back" | "done" | "signup") => {
switch (action) {
case "signup":
// ClinePass: record the intent so App opens the subscription page once auth
// completes (App outlives this view, which unmounts on auth). Login flow unchanged.
setPendingClinePassSubscribe(userType === NEW_USER_TYPE.CLINE_PASS)
setStepNumber(stepNumber + 1)
setIsActionLoading(true)
await AccountServiceClient.accountLoginClicked({})
@@ -337,6 +400,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
await finishOnboarding(true, stepNumber + 1)
break
case "signin":
setPendingClinePassSubscribe(false)
setIsActionLoading(true)
await AccountServiceClient.accountLoginClicked({})
.catch(() => {})
@@ -348,6 +412,8 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
setStepNumber(stepNumber + 1)
break
case "back":
// Abandon any pending ClinePass subscription redirect when the user goes back.
setPendingClinePassSubscribe(false)
StateServiceClient.captureOnboardingProgress({ step: stepNumber - 1 })
setStepNumber(stepNumber - 1)
break
@@ -358,7 +424,7 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
break
}
},
[stepNumber, finishOnboarding, setShowWelcome],
[stepNumber, finishOnboarding, setShowWelcome, userType],
)
const stepDisplayInfo = useMemo(() => {
@@ -394,20 +460,27 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
setSearchTerm={setSearchTerm}
step={stepNumber}
userType={userType}
userTypeSelections={userTypeSelections}
/>
</div>
<footer className="flex w-full max-w-lg flex-col gap-3 my-2 px-2 overflow-hidden flex-shrink-0">
{stepDisplayInfo.buttons.map((btn) => (
<Button
className={`w-full rounded-xs ${isActionLoading ? "animate-pulse" : ""}`}
disabled={isActionLoading}
key={btn.text}
onClick={() => handleFooterAction(btn.action)}
variant={btn.variant}>
{btn.text}
</Button>
))}
{stepDisplayInfo.buttons.map((btn) => {
// Block ClinePass signup when no ClinePass model is selected (e.g. empty list).
const disabled =
isActionLoading ||
(btn.action === "signup" && userType === NEW_USER_TYPE.CLINE_PASS && !selectedModelId)
return (
<Button
className={`w-full rounded-xs ${isActionLoading ? "animate-pulse" : ""}`}
disabled={disabled}
key={btn.text}
onClick={() => handleFooterAction(btn.action)}
variant={btn.variant}>
{btn.text}
</Button>
)
})}
{stepNumber !== 2 && (
<div className="items-center justify-center flex text-sm text-foreground gap-2 mb-3 text-pretty">
@@ -0,0 +1,86 @@
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { describe, expect, it } from "vitest"
import { getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
function model(id: string, group: string): OnboardingModel {
return {
id,
name: id,
group,
badge: "",
score: 0,
latency: 0,
info: undefined,
} as OnboardingModel
}
function groupOf(models: OnboardingModel[]): OnboardingModelGroup {
return { models } as OnboardingModelGroup
}
describe("getClineUIOnboardingGroups", () => {
it("buckets ClinePass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", "clinepass"),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
]),
)
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe("clinepass")
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.free[0].models.map((m) => m.id)).toEqual(["free-model"])
expect(result.power.flatMap((g) => g.models.map((m) => m.id))).toEqual(["anthropic/claude", "z-ai/glm"])
})
it("returns an empty clinePass group when no ClinePass models are present", () => {
const result = getClineUIOnboardingGroups(groupOf([model("free-model", "free")]))
expect(result.clinePass).toEqual([])
})
})
describe("getRecommendedModelsData", () => {
it("ignores ClinePass-only responses when the ClinePass feature flag is disabled", () => {
const result = getRecommendedModelsData(
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
false,
)
expect(result).toBeUndefined()
})
it("includes ClinePass models when the ClinePass feature flag is enabled", () => {
const result = getRecommendedModelsData(
{
recommended: [],
free: [],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
true,
)
expect(result?.clinePass.map((model) => model.id)).toEqual(["cline-pass/glm-5.1"])
})
it("keeps classic recommended/free responses when the ClinePass feature flag is disabled", () => {
const result = getRecommendedModelsData(
{
recommended: [{ id: "anthropic/claude", name: "Claude", description: "", tags: [] }],
free: [{ id: "free-model", name: "Free", description: "", tags: [] }],
clinePass: [{ id: "cline-pass/glm-5.1", name: "GLM 5.1", description: "", tags: [] }],
},
false,
)
expect(result?.recommended.map((model) => model.id)).toEqual(["anthropic/claude"])
expect(result?.free.map((model) => model.id)).toEqual(["free-model"])
expect(result?.clinePass).toEqual([])
})
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest"
import { getUserTypeSelections, NEW_USER_TYPE } from "../data-steps"
describe("getUserTypeSelections", () => {
it("omits the ClinePass option when the flag is disabled", () => {
const selections = getUserTypeSelections(false)
expect(selections.map((s) => s.type)).toEqual([NEW_USER_TYPE.FREE, NEW_USER_TYPE.POWER, NEW_USER_TYPE.BYOK])
expect(selections.some((s) => s.type === NEW_USER_TYPE.CLINE_PASS)).toBe(false)
})
it("inserts ClinePass right after the free option when the flag is enabled", () => {
const selections = getUserTypeSelections(true)
// Free stays first (and remains the default selection); ClinePass is the
// recommended-but-optional second choice.
expect(selections[0]?.type).toBe(NEW_USER_TYPE.FREE)
expect(selections[1]?.type).toBe(NEW_USER_TYPE.CLINE_PASS)
expect(selections.map((s) => s.type)).toEqual([
NEW_USER_TYPE.FREE,
NEW_USER_TYPE.CLINE_PASS,
NEW_USER_TYPE.POWER,
NEW_USER_TYPE.BYOK,
])
})
})
@@ -0,0 +1,28 @@
import { StringRequest } from "@shared/proto/cline/common"
import { UiServiceClient } from "@/services/grpc-client"
// ClinePass subscription signup page in the dashboard (requires auth).
const CLINE_PASS_SUBSCRIBE_PATH = "/onboarding/individual-plan"
const DEFAULT_APP_BASE_URL = "https://app.cline.bot"
// Module-level so the pending intent survives OnboardingView unmounting: handleAuthCallback
// completes the welcome view (unmounting onboarding) before it pushes the auth-status update
// that sets clineUser, so this must outlive the component to fire the redirect.
let pendingClinePassSubscribe = false
export function setPendingClinePassSubscribe(pending: boolean): void {
pendingClinePassSubscribe = pending
}
// Opens the ClinePass subscription page once a pending signup is authenticated (guarded so it fires once).
export function openClinePassSubscriptionIfPending(appBaseUrl: string | undefined): void {
if (!pendingClinePassSubscribe) {
return
}
pendingClinePassSubscribe = false
const baseUrl = appBaseUrl || DEFAULT_APP_BASE_URL
UiServiceClient.openUrl(StringRequest.create({ value: `${baseUrl}${CLINE_PASS_SUBSCRIBE_PATH}` })).catch((err) =>
console.error("Failed to open ClinePass subscription page:", err),
)
}
@@ -1,7 +1,35 @@
import type { OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { ClineRecommendedModel, OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
export interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
clinePass: ClineRecommendedModel[]
}
type RecommendedModelsResponseLike = {
recommended?: ClineRecommendedModel[]
free?: ClineRecommendedModel[]
clinePass?: ClineRecommendedModel[]
}
export function getRecommendedModelsData(
response: RecommendedModelsResponseLike,
isClinePassEnabled: boolean,
): RecommendedModelsData | undefined {
const recommended = response.recommended ?? []
const free = response.free ?? []
const clinePass = isClinePassEnabled ? (response.clinePass ?? []) : []
if (recommended.length === 0 && free.length === 0 && clinePass.length === 0) {
return undefined
}
return { recommended, free, clinePass }
}
export interface OnboardingModelsByGroup {
clinePass: ModelGroup[]
free: ModelGroup[]
power: ModelGroup[]
}
@@ -14,11 +42,13 @@ interface ModelGroup {
export function getClineUIOnboardingGroups(groupedModels: OnboardingModelGroup): OnboardingModelsByGroup {
const { models } = groupedModels
const clinePassModels = models.filter((m) => m.group === "clinepass")
const freeModels = models.filter((m) => m.group === "free")
const frontierModels = models.filter((m) => m.group === "frontier")
const openSourceModels = models.filter((m) => m.group === "open source")
return {
clinePass: clinePassModels.length > 0 ? [{ group: "clinepass", models: clinePassModels }] : [],
free: freeModels.length > 0 ? [{ group: "free", models: freeModels }] : [],
power: [
...(frontierModels.length > 0 ? [{ group: "frontier", models: frontierModels }] : []),
@@ -1,4 +1,5 @@
export enum NEW_USER_TYPE {
CLINE_PASS = "cline-pass",
FREE = "free",
POWER = "power",
BYOK = "byok",
@@ -19,6 +20,13 @@ export const STEP_CONFIG = {
{ text: "Login to Cline", action: "signin", variant: "secondary" },
],
},
[NEW_USER_TYPE.CLINE_PASS]: {
title: "Select a ClinePass model",
buttons: [
{ text: "Create my Account", action: "signup", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
[NEW_USER_TYPE.FREE]: {
title: "Select a free model",
buttons: [
@@ -47,8 +55,29 @@ export const STEP_CONFIG = {
},
} as const
export const USER_TYPE_SELECTIONS: UserTypeSelection[] = [
const CLINE_PASS_USER_TYPE_SELECTION: UserTypeSelection = {
title: "ClinePass (Recommended)",
description: "One subscription, curated models, no API keys",
type: NEW_USER_TYPE.CLINE_PASS,
}
const BASE_USER_TYPE_SELECTIONS: UserTypeSelection[] = [
{ title: "Absolutely Free", description: "Get started at no cost", type: NEW_USER_TYPE.FREE },
{ title: "Frontier Model", description: "Claude, GPT Codex, Gemini, etc.", type: NEW_USER_TYPE.POWER },
{ title: "Bring my own API key", description: "Use Cline with your provider of choice", type: NEW_USER_TYPE.BYOK },
]
/**
* Returns the onboarding user-type options. The free option leads the list and is
* the default selection; ClinePass is inserted as a recommended-but-optional
* choice (labeled "Recommended") right after it, only when the `ext-cline-pass`
* feature flag is enabled. When the flag is off, the classic Free / Frontier /
* BYOK options are shown unchanged.
*/
export function getUserTypeSelections(isClinePassEnabled: boolean): UserTypeSelection[] {
if (!isClinePassEnabled) {
return BASE_USER_TYPE_SELECTIONS
}
const [free, ...rest] = BASE_USER_TYPE_SELECTIONS
return [free, CLINE_PASS_USER_TYPE_SELECTION, ...rest]
}
@@ -1,11 +1,14 @@
import type { ModelInfo } from "@shared/api"
import { buildModelInfoNameMap, type ModelInfo, resolveClinePassModelInfo } from "@shared/api"
import { CLINE_ONBOARDING_MODELS } from "@shared/cline/onboarding"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { ClineRecommendedModel } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { useEffect, useMemo, useState } from "react"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
export type OnboardingModelsStatus = "loading" | "success" | "empty"
@@ -44,15 +47,11 @@ function toOnboardingModel(
}
}
interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
}
type FetchState = { status: "loading" } | { status: "success"; data: RecommendedModelsData } | { status: "empty" }
export function useOnboardingModels(): UseOnboardingModelsResult {
const { openRouterModels, clineModels, refreshClineModels } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const [fetchState, setFetchState] = useState<FetchState>({ status: "loading" })
useEffect(() => {
@@ -62,12 +61,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
try {
const response = await ModelsServiceClient.refreshClineRecommendedModelsRpc(EmptyRequest.create({}))
if (!cancelled) {
const recommended = response.recommended ?? []
const free = response.free ?? []
if (recommended.length === 0 && free.length === 0) {
const data = getRecommendedModelsData(response, isClinePassEnabled)
if (!data) {
setFetchState({ status: "empty" })
} else {
setFetchState({ status: "success", data: { recommended, free } })
setFetchState({ status: "success", data })
}
}
} catch {
@@ -82,7 +80,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return () => {
cancelled = true
}
}, [])
}, [isClinePassEnabled])
useEffect(() => {
refreshClineModels()
@@ -93,6 +91,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
return { ...openRouterModels, ...(clineModels ?? {}) }
}, [openRouterModels, clineModels])
// ClinePass model IDs omit the upstream lab (e.g. "cline-pass/glm-5.1"), so look up
// capabilities via the model slug against the OpenRouter catalog, falling back to
// conservative ClinePass defaults. Mirrors ClinePassProvider's resolution.
const openRouterModelsByName = useMemo(() => buildModelInfoNameMap(openRouterModels), [openRouterModels])
return useMemo<UseOnboardingModelsResult>(() => {
if (fetchState.status !== "success") {
return { status: fetchState.status, models: { models: CLINE_ONBOARDING_MODELS } }
@@ -101,7 +104,11 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
const { data } = fetchState
const freeModels = data.free.map((rec) => toOnboardingModel(rec, "free", "Free", modelCatalog))
const frontierModels = data.recommended.map((rec) => toOnboardingModel(rec, "frontier", "", modelCatalog))
const clinePassCatalog = Object.fromEntries(
data.clinePass.map((rec) => [rec.id, resolveClinePassModelInfo(rec.id, openRouterModelsByName)]),
)
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, "clinepass", "", clinePassCatalog))
return { status: "success", models: { models: [...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog])
return { status: "success", models: { models: [...clinePassModels, ...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog, openRouterModelsByName])
}
@@ -9,6 +9,7 @@ import styled from "styled-components"
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
@@ -58,8 +59,6 @@ import { XaiProvider } from "./providers/XaiProvider"
import { ZAiProvider } from "./providers/ZAiProvider"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
@@ -25,7 +25,7 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
(response.clinePass ?? [])
.filter((model) => model.id)
.map((model) => {
// Cline Pass model IDs omit the upstream lab, so look up capabilities using
// ClinePass model IDs omit the upstream lab, so look up capabilities using
// the model slug (for example, glm-5.1 instead of cline-pass/glm-5.1).
// If the model is not in OpenRouter yet, use conservative generic defaults
// instead of copying GLM-5.1-specific context/max-token values.
@@ -42,7 +42,7 @@ export const ClinePassProvider: typeof ClineProvider = (props) => {
)
setClinePassRecommendedModels(Object.keys(models).length > 0 ? models : undefined)
} catch (error) {
console.error("Failed to refresh Cline Pass models:", error)
console.error("Failed to refresh ClinePass models:", error)
}
}, [openRouterModelsByName])
@@ -0,0 +1,3 @@
// Webview copies of feature-flag strings. Must match the extension's FeatureFlag
// enum, which can't be imported here (it pulls in Node-only deps).
export const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
+15
View File
@@ -0,0 +1,15 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/
# Environment variables
.env*.local
# Common ignores
node_modules
.next/
.DS_Store
+55
View File
@@ -0,0 +1,55 @@
# Welcome App
Next.js onboarding app for the Cline workspace.
## Requirements
- Bun `1.3.13`
- Node.js `22` or newer
## Install
Install dependencies from the repository root so Bun uses the shared workspace lockfile:
```bash
bun install
```
## Development
Run the app from the repository root:
```bash
bun -F @cline/welcome dev
```
Or from this directory:
```bash
bun run dev
```
The development server starts with Next.js and prints the local URL.
## Build
```bash
bun -F @cline/welcome build
```
## Troubleshooting
If `bun run dev` stays on `Compiling / ...`, stop the dev server and check for a Turbopack panic mentioning `No space left on device`. Clear this app's generated cache and retry:
```bash
rm -rf apps/welcome/.next
bun -F @cline/welcome dev
```
If it still hangs, check available disk space with `df -h`.
## Scripts
- `bun run dev` starts the Next.js development server.
- `bun run build` creates a production build.
- `bun run start` serves the production build.
+135
View File
@@ -0,0 +1,135 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
/* Retro purple with warm cream tones */
--background: oklch(0.97 0.01 290);
--foreground: oklch(0.20 0.02 280);
--card: oklch(0.99 0.008 290);
--card-foreground: oklch(0.20 0.02 280);
--popover: oklch(0.99 0.008 290);
--popover-foreground: oklch(0.20 0.02 280);
--primary: oklch(0.55 0.20 285);
--primary-foreground: oklch(0.99 0.005 290);
--secondary: oklch(0.94 0.025 290);
--secondary-foreground: oklch(0.20 0.02 280);
--muted: oklch(0.95 0.015 290);
--muted-foreground: oklch(0.50 0.03 280);
--accent: oklch(0.70 0.18 320);
--accent-foreground: oklch(0.99 0.005 290);
--destructive: oklch(0.55 0.2 25);
--destructive-foreground: oklch(0.99 0.005 290);
--border: oklch(0.88 0.03 290);
--input: oklch(0.94 0.025 290);
--ring: oklch(0.55 0.20 285);
--chart-1: oklch(0.55 0.20 285);
--chart-2: oklch(0.70 0.18 320);
--chart-3: oklch(0.60 0.15 250);
--chart-4: oklch(0.65 0.12 340);
--chart-5: oklch(0.50 0.18 270);
--radius: 0.75rem;
--sidebar: oklch(0.96 0.015 290);
--sidebar-foreground: oklch(0.20 0.02 280);
--sidebar-primary: oklch(0.55 0.20 285);
--sidebar-primary-foreground: oklch(0.99 0.005 290);
--sidebar-accent: oklch(0.94 0.025 290);
--sidebar-accent-foreground: oklch(0.20 0.02 280);
--sidebar-border: oklch(0.88 0.03 290);
--sidebar-ring: oklch(0.55 0.20 285);
/* Custom colors */
--success: oklch(0.60 0.18 285);
--warning: oklch(0.75 0.15 60);
}
.dark {
--background: oklch(0.16 0.03 280);
--foreground: oklch(0.95 0.01 290);
--card: oklch(0.20 0.03 280);
--card-foreground: oklch(0.95 0.01 290);
--popover: oklch(0.20 0.03 280);
--popover-foreground: oklch(0.95 0.01 290);
--primary: oklch(0.65 0.20 285);
--primary-foreground: oklch(0.16 0.03 280);
--secondary: oklch(0.26 0.03 280);
--secondary-foreground: oklch(0.95 0.01 290);
--muted: oklch(0.26 0.03 280);
--muted-foreground: oklch(0.68 0.02 290);
--accent: oklch(0.75 0.18 320);
--accent-foreground: oklch(0.16 0.03 280);
--destructive: oklch(0.5 0.2 25);
--destructive-foreground: oklch(0.95 0.01 290);
--border: oklch(0.30 0.03 280);
--input: oklch(0.26 0.03 280);
--ring: oklch(0.65 0.20 285);
--chart-1: oklch(0.65 0.20 285);
--chart-2: oklch(0.75 0.18 320);
--chart-3: oklch(0.65 0.15 250);
--chart-4: oklch(0.70 0.12 340);
--chart-5: oklch(0.60 0.18 270);
--sidebar: oklch(0.20 0.03 280);
--sidebar-foreground: oklch(0.95 0.01 290);
--sidebar-primary: oklch(0.65 0.20 285);
--sidebar-primary-foreground: oklch(0.16 0.03 280);
--sidebar-accent: oklch(0.26 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 290);
--sidebar-border: oklch(0.30 0.03 280);
--sidebar-ring: oklch(0.65 0.20 285);
--success: oklch(0.65 0.18 285);
--warning: oklch(0.78 0.15 60);
}
@theme inline {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-success: var(--success);
--color-warning: var(--warning);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from 'next'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'
export const metadata: Metadata = {
title: 'Cline Setup',
description: 'Set up your AI agent in minutes',
generator: 'v0.app',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
if (!prefersLight) {
document.documentElement.classList.add('dark');
}
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</head>
<body className="font-sans antialiased bg-background text-foreground">
{children}
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"
export default function Page() {
return (
<main className="min-h-screen">
<OnboardingWizard />
</main>
)
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,263 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { StepConnection } from "./steps/step-connection"
import { StepAgentType } from "./steps/step-agent-type"
import { StepCustomAgent } from "./steps/step-custom-agent"
import { StepPlugins } from "./steps/step-plugins"
import { StepPlatform } from "./steps/step-platform"
import { StepConnectors } from "./steps/step-connectors"
import { StepDone } from "./steps/step-done"
import { ProgressIndicator } from "./progress-indicator"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { useClineHubClient, type ActiveConnector } from "@/lib/hub-client"
export type AgentType = "coding" | "assistant" | "custom" | null
export type Platform = "browser" | "clients" | "messengers" | null
export interface OnboardingState {
isConnected: boolean
agentType: AgentType
customDescription: string
selectedPlugins: string[]
platform: Platform
connectors: ActiveConnector[]
connectorNames: Record<string, string>
}
const pageVariants = {
initial: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
in: {
x: 0,
opacity: 1,
},
out: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
}
const pageTransition = {
type: "spring" as const,
stiffness: 300,
damping: 30,
}
export function OnboardingWizard() {
const hub = useClineHubClient()
const [currentStep, setCurrentStep] = useState(1)
const [direction, setDirection] = useState(0)
const [state, setState] = useState<OnboardingState>({
isConnected: false,
agentType: null,
customDescription: "",
selectedPlugins: [],
platform: null,
connectors: [],
connectorNames: {},
})
const updateState = useCallback((updates: Partial<OnboardingState>) => {
setState((prev) => ({ ...prev, ...updates }))
}, [])
const updateConnectors = useCallback(
(connectors: ActiveConnector[], connectorNames: Record<string, string>) => {
updateState({ connectors, connectorNames })
},
[updateState],
)
const getStepSequence = useCallback((): number[] => {
const sequence = [1, 2]
if (state.agentType === "custom") {
sequence.push(3)
}
sequence.push(4, 5)
if (state.platform === "messengers") {
sequence.push(6)
}
sequence.push(7)
return sequence
}, [state.agentType, state.platform])
const getCurrentStepIndex = useCallback(() => {
const sequence = getStepSequence()
return sequence.indexOf(currentStep)
}, [currentStep, getStepSequence])
const canGoBack = useCallback(() => {
const index = getCurrentStepIndex()
return index > 0 && currentStep !== 1
}, [currentStep, getCurrentStepIndex])
const canGoNext = useCallback(() => {
const sequence = getStepSequence()
const index = getCurrentStepIndex()
if (currentStep === 1) return false // Auto-advances
if (currentStep === 2 && !state.agentType) return false
if (currentStep === 3 && !state.customDescription.trim()) return false
if (currentStep === 5 && !state.platform) return false
if (currentStep === 7) return false // Final step
return index < sequence.length - 1
}, [currentStep, state, getCurrentStepIndex, getStepSequence])
const goToNextStep = useCallback(() => {
const sequence = getStepSequence()
const index = getCurrentStepIndex()
if (index < sequence.length - 1) {
setDirection(1)
setCurrentStep(sequence[index + 1])
}
}, [getCurrentStepIndex, getStepSequence])
const goToPrevStep = useCallback(() => {
const sequence = getStepSequence()
const index = getCurrentStepIndex()
if (index > 0) {
setDirection(-1)
setCurrentStep(sequence[index - 1])
}
}, [getCurrentStepIndex, getStepSequence])
// Handle connection state change
useEffect(() => {
if (state.isConnected && currentStep === 1) {
const timer = setTimeout(() => {
setDirection(1)
setCurrentStep(2)
}, 800)
return () => clearTimeout(timer)
}
}, [state.isConnected, currentStep])
useEffect(() => {
updateState({ isConnected: hub.isConnected })
}, [hub.isConnected, updateState])
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<StepConnection
errorMessage={hub.errorMessage}
isConnected={hub.isConnected}
onRetry={() => void hub.connect({ showProgress: true })}
status={hub.status}
/>
)
case 2:
return (
<StepAgentType
selected={state.agentType}
onSelect={(type) => updateState({ agentType: type })}
/>
)
case 3:
return (
<StepCustomAgent
description={state.customDescription}
onChange={(desc) => updateState({ customDescription: desc })}
/>
)
case 4:
return (
<StepPlugins
selected={state.selectedPlugins}
onToggle={(plugin) => {
const newPlugins = state.selectedPlugins.includes(plugin)
? state.selectedPlugins.filter((p) => p !== plugin)
: [...state.selectedPlugins, plugin]
updateState({ selectedPlugins: newPlugins })
}}
/>
)
case 5:
return (
<StepPlatform
selected={state.platform}
onSelect={(platform) => updateState({ platform })}
/>
)
case 6:
return (
<StepConnectors
connectors={state.connectors}
connectorNames={state.connectorNames}
hub={hub}
onUpdate={updateConnectors}
/>
)
case 7:
return <StepDone state={state} />
default:
return null
}
}
const sequence = getStepSequence()
const totalSteps = sequence.length
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 sm:p-6 md:p-8">
<div className="w-full max-w-2xl">
{/* Progress */}
<ProgressIndicator
currentStep={getCurrentStepIndex() + 1}
totalSteps={totalSteps}
/>
{/* Step Content */}
<div className="relative mt-8 min-h-[400px] sm:min-h-[450px]">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={currentStep}
custom={direction}
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
className="w-full"
>
{renderStep()}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation */}
{currentStep !== 1 && currentStep !== 7 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mt-8 gap-4"
>
<button
onClick={goToPrevStep}
disabled={!canGoBack()}
className="flex items-center gap-2 px-4 py-2.5 sm:px-6 sm:py-3 text-sm sm:text-base font-medium text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-secondary"
>
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
</button>
<button
onClick={goToNextStep}
disabled={!canGoNext()}
className="flex items-center gap-2 px-6 py-2.5 sm:px-8 sm:py-3 text-sm sm:text-base font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
>
<span>Continue</span>
<ArrowRight className="w-4 h-4" />
</button>
</motion.div>
)}
</div>
</div>
)
}
@@ -0,0 +1,33 @@
"use client"
import { motion } from "framer-motion"
interface ProgressIndicatorProps {
currentStep: number
totalSteps: number
}
export function ProgressIndicator({ currentStep, totalSteps }: ProgressIndicatorProps) {
const progress = (currentStep / totalSteps) * 100
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-mono text-muted-foreground">
Step {currentStep} of {totalSteps}
</span>
<span className="text-sm font-mono text-muted-foreground">
{Math.round(progress)}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
</div>
</div>
)
}
@@ -0,0 +1,106 @@
"use client"
import { motion } from "framer-motion"
import { Code2, Sparkles, Wrench } from "lucide-react"
import type { AgentType } from "../onboarding-wizard"
interface StepAgentTypeProps {
selected: AgentType
onSelect: (type: AgentType) => void
}
const agents = [
{
id: "coding" as const,
title: "Coding Agent",
description: "Write, debug & refactor code",
icon: Code2,
},
{
id: "assistant" as const,
title: "Personal Assistant",
description: "Manage tasks & schedules",
icon: Sparkles,
},
{
id: "custom" as const,
title: "Build Your Own",
description: "Design a custom agent",
icon: Wrench,
},
]
export function StepAgentType({ selected, onSelect }: StepAgentTypeProps) {
return (
<div className="text-center px-4">
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance"
>
Pick your agent
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-muted-foreground mb-8 text-sm sm:text-base"
>
Choose a preset or build your own
</motion.p>
<div className="grid gap-4 max-w-lg mx-auto">
{agents.map((agent, index) => {
const Icon = agent.icon
const isSelected = selected === agent.id
return (
<motion.button
key={agent.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => onSelect(agent.id)}
className={`flex items-center gap-4 p-4 sm:p-5 rounded-xl border-2 text-left transition-all ${
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-primary/50 hover:bg-secondary/50"
}`}
>
<div
className={`flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-lg flex items-center justify-center transition-colors ${
isSelected ? "bg-primary text-primary-foreground" : "bg-secondary text-foreground"
}`}
>
<Icon className="w-6 h-6 sm:w-7 sm:h-7" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-base sm:text-lg">
{agent.title}
</h3>
<p className="text-muted-foreground text-sm">{agent.description}</p>
</div>
<div
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 transition-all ${
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30"
}`}
>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-full h-full flex items-center justify-center"
>
<div className="w-2 h-2 bg-primary-foreground rounded-full" />
</motion.div>
)}
</div>
</motion.button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,188 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
import {
Check,
CheckCircle2,
Copy,
RefreshCw,
Terminal,
Wifi,
WifiOff,
} from "lucide-react"
import type { HubConnectionStatus } from "@/lib/hub-client"
interface StepConnectionProps {
errorMessage: string | null
isConnected: boolean
onRetry: () => void
status: HubConnectionStatus
}
const DASHBOARD_COMMAND = "cline dashboard"
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
} catch {
// Fall back to the selection-based copy path for browsers that block the Clipboard API.
}
}
const textArea = document.createElement("textarea")
textArea.value = text
textArea.setAttribute("readonly", "")
textArea.style.left = "-9999px"
textArea.style.position = "fixed"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textArea)
if (!copied) {
throw new Error("Unable to copy command.")
}
}
export function StepConnection({
errorMessage,
isConnected,
onRetry,
status,
}: StepConnectionProps) {
const checking = status === "connecting"
const [copied, setCopied] = useState(false)
const copiedResetTimeout = useRef<number | null>(null)
const copyDashboardCommand = useCallback(async () => {
try {
await copyTextToClipboard(DASHBOARD_COMMAND)
setCopied(true)
if (copiedResetTimeout.current) {
window.clearTimeout(copiedResetTimeout.current)
}
copiedResetTimeout.current = window.setTimeout(() => setCopied(false), 1600)
} catch {
setCopied(false)
}
}, [])
useEffect(() => {
return () => {
if (copiedResetTimeout.current) {
window.clearTimeout(copiedResetTimeout.current)
}
}
}, [])
return (
<div className="text-center px-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="mb-6"
>
{isConnected ? (
<div className="inline-flex items-center justify-center w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-primary/10">
<CheckCircle2 className="w-10 h-10 sm:w-12 sm:h-12 text-primary" />
</div>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-accent/10">
{checking ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>
<Wifi className="w-10 h-10 sm:w-12 sm:h-12 text-accent" />
</motion.div>
) : (
<WifiOff className="w-10 h-10 sm:w-12 sm:h-12 text-muted-foreground" />
)}
</div>
)}
</motion.div>
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance">
{isConnected ? "Connected!" : checking ? "Connecting..." : "Not connected"}
</h2>
<p className="text-muted-foreground mb-8 max-w-md mx-auto text-sm sm:text-base">
{isConnected
? "Cline Hub is ready. Let's set up your agent."
: "Run the local Hub dashboard so this page can finish setup."}
</p>
{!isConnected && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-card border-2 border-border rounded-xl p-4 sm:p-6 max-w-md mx-auto"
>
<div className="flex items-center gap-2 mb-3">
<Terminal className="w-4 h-4 text-muted-foreground" />
<span className="text-xs sm:text-sm font-medium text-muted-foreground uppercase tracking-wide">
Run this command
</span>
</div>
<button
aria-label={`Copy ${DASHBOARD_COMMAND}`}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-foreground/5 p-3 text-left font-mono text-sm text-foreground transition-colors hover:bg-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-4 sm:text-base"
onClick={() => void copyDashboardCommand()}
type="button"
>
<span>{DASHBOARD_COMMAND}</span>
<span className="inline-flex items-center gap-1.5 font-sans text-xs font-medium text-muted-foreground">
{copied ? (
<>
<Check className="h-3.5 w-3.5" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Copy
</>
)}
</span>
</button>
<p className="text-xs text-muted-foreground mt-3">
{checking ? "Looking for Cline Hub..." : "Cline Hub is not connected."}
</p>
{errorMessage && !checking ? (
<p className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-left text-xs text-destructive">
{errorMessage}
</p>
) : null}
<button
className="mt-4 inline-flex items-center justify-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={checking}
onClick={onRetry}
type="button"
>
<RefreshCw className={`h-4 w-4 ${checking ? "animate-spin" : ""}`} />
Retry
</button>
</motion.div>
)}
{isConnected && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium"
>
<span className="w-2 h-2 bg-primary rounded-full animate-pulse" />
Continuing...
</motion.div>
)}
</div>
)
}
@@ -0,0 +1,641 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Circle, MessageCircle, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type {
ActiveConnector,
ConnectorChannel,
ConnectorChannelsResponse,
ConnectorField,
ConnectorSecurityField,
useClineHubClient,
} from "@/lib/hub-client";
import { cn } from "@/lib/utils";
type HubClient = ReturnType<typeof useClineHubClient>;
type ConnectorFormState = {
channelId: string;
values: Record<string, string>;
securityEnabled: boolean;
securityValues: Record<string, string>;
};
interface StepConnectorsProps {
connectors: ActiveConnector[];
connectorNames: Record<string, string>;
hub: HubClient;
onUpdate: (
connectors: ActiveConnector[],
connectorNames: Record<string, string>,
) => void;
}
function connectorName(
connector: ActiveConnector,
channels: ConnectorChannel[],
fallbackNames: Record<string, string>,
): string {
return (
channels.find((channel) => channel.id === connector.type)?.name ??
fallbackNames[connector.type] ??
connector.type
);
}
function connectorIdentity(connector: ActiveConnector): string {
if (connector.botUsername) return `@${connector.botUsername}`;
if (connector.userName) return connector.userName;
if (connector.applicationId) return connector.applicationId;
if (connector.phoneNumberId) return connector.phoneNumberId;
return `pid ${connector.pid}`;
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function isSecretField(
field: ConnectorField | ConnectorSecurityField,
): boolean {
const label = field.label.toLowerCase();
const key =
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
return (
label.includes("token") ||
label.includes("secret") ||
label.includes("key") ||
key.includes("token") ||
key.includes("secret") ||
key.includes("key")
);
}
function isMultilineField(field: ConnectorField): boolean {
const label = field.label.toLowerCase();
return label.includes("json") || field.flag.includes("credentials");
}
function shouldIncludeField(
field: ConnectorField,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) return true;
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function initialValuesForChannel(
channel?: ConnectorChannel,
): Record<string, string> {
const values: Record<string, string> = {};
for (const field of channel?.fields ?? []) {
if (field.initialValue) {
values[field.flag] = field.initialValue;
}
}
return values;
}
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
const channel = channels[0];
return {
channelId: channel?.id ?? "",
values: initialValuesForChannel(channel),
securityEnabled: false,
securityValues: {},
};
}
function namesByChannel(channels: ConnectorChannel[]): Record<string, string> {
return Object.fromEntries(
channels.map((channel) => [channel.id, channel.name]),
);
}
export function StepConnectors({
connectors,
connectorNames,
hub,
onUpdate,
}: StepConnectorsProps) {
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [busyChannel, setBusyChannel] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [formState, setFormState] = useState<ConnectorFormState>({
channelId: "",
values: {},
securityEnabled: false,
securityValues: {},
});
const [formError, setFormError] = useState<string | null>(null);
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
null,
);
const selectedChannel = useMemo(
() => channels.find((channel) => channel.id === formState.channelId),
[channels, formState.channelId],
);
const visibleFields = useMemo(() => {
const values = {
...initialValuesForChannel(selectedChannel),
...formState.values,
};
return (selectedChannel?.fields ?? []).filter((field) =>
shouldIncludeField(field, values),
);
}, [selectedChannel, formState.values]);
const applyResponse = useCallback(
(response: ConnectorChannelsResponse) => {
const nextNames = namesByChannel(response.available);
setChannels(response.available);
setFormState((prev) =>
prev.channelId ? prev : createFormState(response.available),
);
onUpdate(response.active, nextNames);
},
[onUpdate],
);
const refreshChannels = useCallback(async () => {
if (!hub.isConnected) {
setIsLoading(false);
setErrorMessage("Connect to Cline Hub before adding channels.");
return;
}
setIsLoading(true);
setErrorMessage(null);
try {
const response = await hub.invoke<ConnectorChannelsResponse>(
"list_connector_channels",
);
applyResponse(response);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error));
} finally {
setIsLoading(false);
}
}, [applyResponse, hub.invoke, hub.isConnected]);
useEffect(() => {
void refreshChannels();
}, [refreshChannels]);
const openAddDialog = () => {
setFormState(createFormState(channels));
setFormError(null);
setDialogOpen(true);
};
const updateFieldValue = (flag: string, value: string) => {
setFormState((prev) => ({
...prev,
values: { ...prev.values, [flag]: value },
}));
};
const updateSecurityFieldValue = (key: string, value: string) => {
setFormState((prev) => ({
...prev,
securityValues: { ...prev.securityValues, [key]: value },
}));
};
const startConnector = async () => {
if (!selectedChannel) {
setFormError("Choose a channel");
return;
}
for (const field of selectedChannel.fields) {
if (!visibleFields.includes(field)) continue;
if (field.required && !formState.values[field.flag]?.trim()) {
setFormError(`${field.label} is required`);
return;
}
}
if (formState.securityEnabled && selectedChannel.security) {
for (const field of selectedChannel.security.fields) {
if (!formState.securityValues[field.key]?.trim()) {
setFormError(field.requiredMessage);
return;
}
}
}
setBusyChannel(selectedChannel.id);
setFormError(null);
setErrorMessage(null);
try {
const response = await hub.invoke<ConnectorChannelsResponse>(
"start_connector_channel",
{
channel: selectedChannel.id,
values: formState.values,
security: {
enabled: formState.securityEnabled,
values: formState.securityValues,
},
},
);
applyResponse(response);
setDialogOpen(false);
} catch (error) {
setFormError(error instanceof Error ? error.message : String(error));
} finally {
setBusyChannel(null);
}
};
const stopConnector = async (connector: ActiveConnector) => {
setBusyChannel(connector.type);
setErrorMessage(null);
try {
const response = await hub.invoke<ConnectorChannelsResponse>(
"stop_connector_channel",
{ channel: connector.type },
);
applyResponse(response);
setRemoveTarget(null);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusyChannel(null);
}
};
return (
<div className="px-4 text-center">
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-3 text-balance text-2xl font-bold text-foreground sm:text-3xl"
>
Connect Cline
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="mb-6 text-sm text-muted-foreground sm:text-base"
>
Start and manage messaging channels.
</motion.p>
<div className="mx-auto max-w-lg space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-left text-xs text-muted-foreground">
{connectors.length} connected
</p>
<Button
disabled={isLoading || !hub.isConnected}
onClick={() => void refreshChannels()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw
className={cn("size-4", isLoading && "animate-spin")}
/>
Refresh
</Button>
</div>
{errorMessage ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
{errorMessage}
</div>
) : null}
<AnimatePresence mode="popLayout">
{connectors.map((connector) => (
<motion.div
key={connector.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="overflow-hidden rounded-lg border-2 border-border bg-card p-4 text-left"
>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-400 text-emerald-400" />
<p className="truncate font-semibold text-foreground">
{connectorName(connector, channels, connectorNames)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
</div>
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove
</Button>
</div>
</motion.div>
))}
</AnimatePresence>
{isLoading ? (
<div className="rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
Loading channels...
</div>
) : connectors.length === 0 ? (
<div className="rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
No channels connected.
</div>
) : null}
<Button
className="h-auto w-full justify-center rounded-xl border-2 border-dashed py-4"
disabled={!hub.isConnected || channels.length === 0}
onClick={openAddDialog}
type="button"
variant="outline"
>
<Plus className="size-5" />
Add Connector
</Button>
<p className="text-sm text-muted-foreground">
Add a connector or skip this step.
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Connector</DialogTitle>
<DialogDescription>
Start a connector channel through Cline Hub.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2 text-left">
<div className="grid gap-2">
<Label>Channel</Label>
<Select
onValueChange={(value) => {
if (!value) return;
setFormState({
channelId: value,
values: initialValuesForChannel(
channels.find((channel) => channel.id === value),
),
securityEnabled: false,
securityValues: {},
});
}}
value={formState.channelId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedChannel?.hint ? (
<p className="text-xs text-muted-foreground">
{selectedChannel.hint}
</p>
) : null}
</div>
{visibleFields.map((field) => (
<div className="grid gap-2" key={field.flag}>
<Label>
{field.label}
{field.required ? (
<span className="text-destructive"> *</span>
) : null}
</Label>
{field.options ? (
<Select
onValueChange={(value) => {
if (value) updateFieldValue(field.flag, value);
}}
value={
formState.values[field.flag] ?? field.initialValue ?? ""
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : isMultilineField(field) ? (
<Textarea
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
rows={5}
value={formState.values[field.flag] ?? ""}
/>
) : (
<Input
onChange={(event) =>
updateFieldValue(field.flag, event.target.value)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.values[field.flag] ?? ""}
/>
)}
{field.help?.length ? (
<p className="text-xs text-muted-foreground">
{field.help.join(" ")}
</p>
) : null}
</div>
))}
{selectedChannel?.security ? (
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<Label className="text-sm">Restrict access</Label>
<p className="mt-1 text-xs text-muted-foreground">
{selectedChannel.security.prompt}
</p>
</div>
<Switch
checked={formState.securityEnabled}
onCheckedChange={(checked: boolean) =>
setFormState((prev) => ({
...prev,
securityEnabled: checked,
}))
}
/>
</div>
{formState.securityEnabled
? selectedChannel.security.fields.map((field) => (
<div className="grid gap-2" key={field.key}>
<Label>{field.label}</Label>
<Input
onChange={(event) =>
updateSecurityFieldValue(
field.key,
event.target.value,
)
}
placeholder={field.placeholder}
type={isSecretField(field) ? "password" : "text"}
value={formState.securityValues[field.key] ?? ""}
/>
{field.help?.length ? (
<p className="text-xs text-muted-foreground">
{field.help.join(" ")}
</p>
) : null}
</div>
))
: null}
</div>
) : null}
{formError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{formError}
</div>
) : null}
</div>
<DialogFooter>
<Button
disabled={busyChannel !== null}
onClick={() => setDialogOpen(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={busyChannel !== null || !selectedChannel}
onClick={() => void startConnector()}
type="button"
>
{busyChannel ? "Starting..." : "Add Connector"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={removeTarget !== null}
onOpenChange={(open: boolean) => {
if (!open) setRemoveTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Connector</AlertDialogTitle>
<AlertDialogDescription>
Confirm that you want to stop the active{" "}
{removeTarget
? connectorName(removeTarget, channels, connectorNames)
: "connector"}{" "}
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyChannel !== null}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busyChannel !== null || !removeTarget}
onClick={() => {
if (removeTarget) void stopConnector(removeTarget);
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,73 @@
"use client"
import { motion } from "framer-motion"
import { Lightbulb } from "lucide-react"
interface StepCustomAgentProps {
description: string
onChange: (description: string) => void
}
const suggestions = [
"Research assistant for papers",
"Support bot for my app",
"Writing coach for emails",
"Data analyst for spreadsheets",
]
export function StepCustomAgent({ description, onChange }: StepCustomAgentProps) {
return (
<div className="text-center px-4">
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance"
>
What should it do?
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-muted-foreground mb-8 text-sm sm:text-base"
>
Describe your agent in a few words
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="max-w-lg mx-auto"
>
<textarea
value={description}
onChange={(e) => onChange(e.target.value)}
placeholder="My agent will..."
className="w-full h-32 sm:h-40 p-4 text-base bg-card border-2 border-border rounded-xl resize-none focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all placeholder:text-muted-foreground/50"
/>
<div className="mt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-3">
<Lightbulb className="w-4 h-4" />
<span className="text-sm font-medium">Try these:</span>
</div>
<div className="flex flex-wrap gap-2 justify-center">
{suggestions.map((suggestion, index) => (
<motion.button
key={suggestion}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 + index * 0.05 }}
onClick={() => onChange(suggestion)}
className="px-3 py-1.5 text-xs sm:text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground rounded-full transition-colors"
>
{suggestion}
</motion.button>
))}
</div>
</div>
</motion.div>
</div>
)
}
@@ -0,0 +1,119 @@
"use client"
import { motion } from "framer-motion"
import { PartyPopper, Rocket, ArrowRight } from "lucide-react"
import type { OnboardingState } from "../onboarding-wizard"
interface StepDoneProps {
state: OnboardingState
}
export function StepDone({ state }: StepDoneProps) {
const agentLabel =
state.agentType === "coding"
? "Coding Agent"
: state.agentType === "assistant"
? "Personal Assistant"
: "Custom Agent"
const connectorLabels = state.connectors.map(
(connector) => state.connectorNames[connector.type] ?? connector.type,
)
return (
<div className="text-center px-4">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
className="mb-6"
>
<div className="inline-flex items-center justify-center w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-primary/10">
<PartyPopper className="w-10 h-10 sm:w-12 sm:h-12 text-primary" />
</div>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance"
>
All done!
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-muted-foreground mb-8 text-sm sm:text-base"
>
Your agent is ready
</motion.p>
{/* Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-card border-2 border-border rounded-xl p-4 sm:p-6 max-w-md mx-auto mb-8"
>
<h3 className="font-semibold text-foreground mb-4 text-left">Summary</h3>
<div className="space-y-3 text-sm text-left">
<div className="flex justify-between">
<span className="text-muted-foreground">Agent Type</span>
<span className="font-medium text-foreground">{agentLabel}</span>
</div>
{state.agentType === "custom" && state.customDescription && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground flex-shrink-0">Description</span>
<span className="font-medium text-foreground truncate">
{state.customDescription}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Plugins</span>
<span className="font-medium text-foreground">
{state.selectedPlugins.length} selected
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Platform</span>
<span className="font-medium text-foreground capitalize">
{state.platform === "clients"
? "Cline Clients"
: state.platform === "messengers"
? "Messengers"
: state.platform}
</span>
</div>
{state.platform === "messengers" && state.connectors.length > 0 && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Connectors</span>
<span className="truncate font-medium text-foreground">
{connectorLabels.join(", ")}
</span>
</div>
)}
</div>
</motion.div>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row items-center justify-center gap-3"
>
<button className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl font-medium hover:opacity-90 transition-all shadow-md hover:shadow-lg w-full sm:w-auto justify-center">
<Rocket className="w-5 h-5" />
<span>Launch Agent</span>
</button>
<button className="flex items-center gap-2 px-6 py-3 bg-secondary text-secondary-foreground rounded-xl font-medium hover:bg-secondary/80 transition-colors w-full sm:w-auto justify-center">
<span>View Dashboard</span>
<ArrowRight className="w-4 h-4" />
</button>
</motion.div>
</div>
)
}
@@ -0,0 +1,106 @@
"use client"
import { motion } from "framer-motion"
import { Globe, Monitor, MessageCircle } from "lucide-react"
import type { Platform } from "../onboarding-wizard"
interface StepPlatformProps {
selected: Platform
onSelect: (platform: Platform) => void
}
const platforms = [
{
id: "browser" as const,
title: "Browser",
description: "Use in any web browser",
icon: Globe,
},
{
id: "clients" as const,
title: "Cline Clients",
description: "CLI, VS Code & more",
icon: Monitor,
},
{
id: "messengers" as const,
title: "Messengers",
description: "Discord, Telegram & more",
icon: MessageCircle,
},
]
export function StepPlatform({ selected, onSelect }: StepPlatformProps) {
return (
<div className="text-center px-4">
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance"
>
Where will you use it?
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-muted-foreground mb-8 text-sm sm:text-base"
>
Pick your platform
</motion.p>
<div className="grid gap-4 max-w-lg mx-auto">
{platforms.map((platform, index) => {
const Icon = platform.icon
const isSelected = selected === platform.id
return (
<motion.button
key={platform.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => onSelect(platform.id)}
className={`flex items-center gap-4 p-4 sm:p-5 rounded-xl border-2 text-left transition-all ${
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-primary/50 hover:bg-secondary/50"
}`}
>
<div
className={`flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-lg flex items-center justify-center transition-colors ${
isSelected ? "bg-primary text-primary-foreground" : "bg-secondary text-foreground"
}`}
>
<Icon className="w-6 h-6 sm:w-7 sm:h-7" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-base sm:text-lg">
{platform.title}
</h3>
<p className="text-muted-foreground text-sm">{platform.description}</p>
</div>
<div
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 transition-all ${
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30"
}`}
>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-full h-full flex items-center justify-center"
>
<div className="w-2 h-2 bg-primary-foreground rounded-full" />
</motion.div>
)}
</div>
</motion.button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,92 @@
"use client";
import { motion } from "framer-motion";
import { Check } from "lucide-react";
interface StepPluginsProps {
selected: string[];
onToggle: (plugin: string) => void;
}
const plugins = [
{ id: "linear", name: "Linear", category: "Project Management" },
{ id: "gmail", name: "Gmail", category: "Email" },
{ id: "google-docs", name: "Google Docs", category: "Documents" },
{ id: "slack", name: "Slack", category: "Communication" },
{ id: "notion", name: "Notion", category: "Notes" },
{ id: "github", name: "GitHub", category: "Development" },
{ id: "calendar", name: "Google Calendar", category: "Scheduling" },
{ id: "jira", name: "Jira", category: "Project Management" },
{ id: "figma", name: "Figma", category: "Design" },
{ id: "dropbox", name: "Dropbox", category: "Storage" },
{ id: "trello", name: "Trello", category: "Tasks" },
{ id: "asana", name: "Asana", category: "Tasks" },
];
export function StepPlugins({ selected, onToggle }: StepPluginsProps) {
return (
<div className="text-center px-4">
<motion.h2
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl sm:text-3xl font-bold text-foreground mb-3 text-balance"
>
Add plugins
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-muted-foreground mb-6 text-sm sm:text-base"
>
Give your agent extra powers
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="text-xs text-muted-foreground mb-6"
>
{selected.length} selected
</motion.p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-3 max-w-xl mx-auto">
{plugins.map((plugin, index) => {
const isSelected = selected.includes(plugin.id);
return (
<motion.button
key={plugin.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.03 }}
onClick={() => onToggle(plugin.id)}
className={`relative p-3 sm:p-4 rounded-xl border-2 text-left transition-all ${
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-primary/50"
}`}
>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-2 right-2 w-5 h-5 bg-primary rounded-full flex items-center justify-center"
>
<Check className="w-3 h-3 text-primary-foreground" />
</motion.div>
)}
<h3 className="font-medium text-foreground text-sm sm:text-base truncate pr-6">
{plugin.name}
</h3>
<p className="text-xs text-muted-foreground truncate">
{plugin.category}
</p>
</motion.button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+157
View File
@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,11 @@
'use client'
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+53
View File
@@ -0,0 +1,53 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,83 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+213
View File
@@ -0,0 +1,213 @@
'use client'
import * as React from 'react'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+241
View File
@@ -0,0 +1,241 @@
'use client'
import * as React from 'react'
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}
return context
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollPrev()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+351
View File
@@ -0,0 +1,351 @@
'use client'
import * as React from 'react'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children']
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className="[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,33 @@
'use client'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
'use client'
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
'use client'
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,257 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
className,
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
className,
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
},
)
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
className,
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useMemo } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
)
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className,
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center',
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
)
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+77
View File
@@ -0,0 +1,77 @@
'use client'
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn('flex items-center', className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className,
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className,
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className,
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }
+276
View File
@@ -0,0 +1,276 @@
'use client'
import * as React from 'react'
import * as MenubarPrimitive from '@radix-ui/react-menubar'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className,
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className,
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
@@ -0,0 +1,166 @@
import * as React from 'react'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className,
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className,
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className="absolute top-full left-0 isolate z-50 flex justify-center"
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react'
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,45 @@
'use client'
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+56
View File
@@ -0,0 +1,56 @@
'use client'
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -0,0 +1,58 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+185
View File
@@ -0,0 +1,185 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
)
}
export { Separator }

Some files were not shown because too many files have changed in this diff Show More