flue flow fixes

This commit is contained in:
-Puter
2026-07-31 23:43:49 +05:30
parent bb4bd9e0d3
commit 8bd3953440
52 changed files with 814 additions and 484 deletions
+45 -2
View File
@@ -1,7 +1,34 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import { withTurnAdmission } from "../adapters/admission-context";
const bindTurnSubmission = makeFunctionReference<
"mutation",
{
readonly clientRequestId: string;
readonly submissionId: string;
readonly token: string;
readonly turnId: string;
},
null
>("conversationMessages:bindTurnSubmission");
const readSubmissionId = async (response: Response): Promise<string | null> => {
const body: unknown = await response
.clone()
.json()
.catch(() => null);
if (
typeof body !== "object" ||
body === null ||
!("submissionId" in body) ||
typeof body.submissionId !== "string"
) {
return null;
}
return body.submissionId;
};
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const conversationRoute: AgentRouteHandler = async (context, next) => {
@@ -26,5 +53,21 @@ export const conversationRoute: AgentRouteHandler = async (context, next) => {
return context.json({ error: "Missing turn correlation headers" }, 400);
}
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
return await next().then(async () => {
if (!context.res.ok) {
return context.res;
}
const submissionId = await readSubmissionId(context.res);
if (submissionId === null) {
return context.json({ error: "Flue admission response is invalid" }, 502);
}
const client = new ConvexHttpClient(env.CONVEX_URL);
await client.mutation(bindTurnSubmission, {
clientRequestId,
submissionId,
token: env.FLUE_DB_TOKEN,
turnId,
});
return context.res;
});
};
+1
View File
@@ -0,0 +1 @@
export { default } from "./adapters/flue-persistence";
+4 -10
View File
@@ -1,14 +1,8 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"types": [
"bun"
]
"types": ["bun"]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"dist"
]
}
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}
+5 -2
View File
@@ -1,6 +1,9 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@code/env/native";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import {
convexClient,
crossDomainClient,
} from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
@@ -9,7 +12,7 @@ import { Platform } from "react-native";
const scheme = Constants.expoConfig?.scheme;
if (typeof scheme !== "string") {
throw new Error("Expo auth requires a string app scheme in app.json");
throw new TypeError("Expo auth requires a string app scheme in app.json");
}
export const authClient = createAuthClient({
+20 -10
View File
@@ -1,12 +1,14 @@
import { useForm } from "@tanstack/react-form";
import { useToast } from "heroui-native";
import { getErrorMessage, signInSchema, signUpSchema } from "../shared/forms";
import { signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
export { getErrorMessage } from "../shared/forms";
interface AuthFormOptions {
readonly onSuccess?: () => void;
};
}
export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
@@ -18,14 +20,17 @@ export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({ label: error.message || "Failed to sign in", variant: "danger" });
toast.show({
label: error.message || "Failed to sign in",
variant: "danger",
});
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Signed in successfully", variant: "success" });
onSuccess?.();
},
},
}
);
},
validators: { onSubmit: signInSchema },
@@ -39,7 +44,11 @@ export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
email: value.email.trim(),
name: value.name.trim(),
password: value.password,
},
{
onError: ({ error }) => {
toast.show({
@@ -49,14 +58,15 @@ export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Account created successfully", variant: "success" });
toast.show({
label: "Account created successfully",
variant: "success",
});
onSuccess?.();
},
},
}
);
},
validators: { onSubmit: signUpSchema },
});
};
export { getErrorMessage };
+17 -5
View File
@@ -9,14 +9,15 @@ import {
useThemeColor,
} from "heroui-native";
import { useRef } from "react";
import { Text, type TextInput, View } from "react-native";
import { Text, View } from "react-native";
import type { TextInput } from "react-native";
import { getErrorMessage, useNativeSignInForm } from "./hooks";
type NativeLoginFormProps = {
interface NativeLoginFormProps {
readonly onNavigateSignUp: () => void;
readonly onSuccess?: () => void;
};
}
export const NativeLoginForm = ({
onNavigateSignUp,
@@ -29,7 +30,14 @@ export const NativeLoginForm = ({
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
<Text
style={{
color: foregroundColor,
fontSize: 24,
fontWeight: "600",
marginBottom: 4,
}}
>
Welcome back
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
@@ -93,7 +101,11 @@ export const NativeLoginForm = ({
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? <Spinner color="default" size="sm" /> : <Button.Label>Sign in</Button.Label>}
{isSubmitting ? (
<Spinner color="default" size="sm" />
) : (
<Button.Label>Sign in</Button.Label>
)}
</Button>
<Button onPress={onNavigateSignUp} variant="ghost">
+10 -3
View File
@@ -12,10 +12,10 @@ import { Text, View } from "react-native";
import { getErrorMessage, useNativeSignUpForm } from "./hooks";
type NativeSignupFormProps = {
interface NativeSignupFormProps {
readonly onNavigateSignIn: () => void;
readonly onSuccess?: () => void;
};
}
export const NativeSignupForm = ({
onNavigateSignIn,
@@ -27,7 +27,14 @@ export const NativeSignupForm = ({
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
<Text
style={{
color: foregroundColor,
fontSize: 24,
fontWeight: "600",
marginBottom: 4,
}}
>
Create an account
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
+27 -7
View File
@@ -1,16 +1,30 @@
import { z } from "zod";
export const signInSchema = z.object({
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
});
export const signUpSchema = z
.object({
confirmPassword: z.string().min(1, "Confirm your password"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
email: z
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address"),
name: z.string().trim().min(2, "Name must be at least 2 characters"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
password: z
.string()
.min(1, "Password is required")
.min(8, "Use at least 8 characters"),
})
.refine(({ confirmPassword, password }) => confirmPassword === password, {
message: "Passwords do not match",
@@ -18,13 +32,19 @@ export const signUpSchema = z
});
export const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (typeof error === "string") return error;
if (!error) {
return null;
}
if (typeof error === "string") {
return error;
}
if (Array.isArray(error)) {
for (const issue of error) {
const message = getErrorMessage(issue);
if (message) return message;
if (message) {
return message;
}
}
return null;
}
+11 -4
View File
@@ -38,7 +38,9 @@ export const LoginForm = ({
<Card>
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>Enter your email and password to continue.</CardDescription>
<CardDescription>
Enter your email and password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form
@@ -58,7 +60,9 @@ export const LoginForm = ({
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
onChange={(event) =>
field.handleChange(event.target.value)
}
placeholder="you@example.com"
type="email"
value={field.state.value}
@@ -77,7 +81,9 @@ export const LoginForm = ({
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
onChange={(event) =>
field.handleChange(event.target.value)
}
type="password"
value={field.state.value}
/>
@@ -100,7 +106,8 @@ export const LoginForm = ({
{isSubmitting ? "Signing in…" : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href={signUpHref}>Sign up</a>
Don&apos;t have an account?{" "}
<a href={signUpHref}>Sign up</a>
</FieldDescription>
</Field>
)}
+6 -2
View File
@@ -35,7 +35,9 @@ export const SignupForm = ({
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Use your name, email, and password to get started.</CardDescription>
<CardDescription>
Use your name, email, and password to get started.
</CardDescription>
</CardHeader>
<CardContent>
<form
@@ -96,7 +98,9 @@ export const SignupForm = ({
type="password"
value={field.state.value}
/>
<FieldDescription>Use at least 8 characters.</FieldDescription>
<FieldDescription>
Use at least 8 characters.
</FieldDescription>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
+2 -6
View File
@@ -2,12 +2,8 @@
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read
`convex/_generated/ai/guidelines.md` first** for important guidelines on
how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running
`npx convex ai-files install`.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
@@ -1,3 +1,4 @@
import { env } from "@code/env/convex";
import { convexTest } from "convex-test";
import { anyApi, makeFunctionReference } from "convex/server";
import { describe, expect, test } from "vitest";
@@ -168,4 +169,43 @@ describe("conversationMessages", () => {
submissionId: "submission-1",
});
});
test("binds a Flue admission receipt exactly once", async () => {
const t = newTest();
const organization = await ensureOrg(t, identityA);
const queued = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.send, {
clientRequestId: "request-bind",
images: [],
organizationId: organization._id,
rawText: "Bind me",
});
await t.mutation(markProcessingRef, {
attempt: 1,
leaseOwner: "worker-bind",
turnId: queued.turnId,
});
await t.mutation(api.conversationMessages.bindTurnSubmission, {
clientRequestId: "request-bind",
submissionId: "submission-bind",
token: env.FLUE_DB_TOKEN,
turnId: queued.turnId,
});
await t.mutation(api.conversationMessages.bindTurnSubmission, {
clientRequestId: "request-bind",
submissionId: "submission-bind",
token: env.FLUE_DB_TOKEN,
turnId: queued.turnId,
});
const turn = await t.run((ctx) => ctx.db.get(queued.turnId));
expect(turn).toMatchObject({
status: "running",
submissionId: "submission-bind",
});
expect(turn).not.toHaveProperty("leaseExpiresAt");
expect(turn).not.toHaveProperty("leaseOwner");
});
});
@@ -263,6 +263,38 @@ export const markProcessing = internalMutation({
},
});
export const bindTurnSubmission = mutation({
args: {
clientRequestId: v.string(),
submissionId: v.string(),
token: v.string(),
turnId: v.id("conversationTurns"),
},
handler: async (ctx, args): Promise<null> => {
if (args.token !== env.FLUE_DB_TOKEN) {
throw new Error("Invalid agent control token");
}
const turn = await ctx.db.get(args.turnId);
if (!turn || turn.clientRequestId !== args.clientRequestId) {
throw new Error("Turn admission correlation does not match");
}
if (turn.status === "running" && turn.submissionId === args.submissionId) {
return null;
}
if (turn.status !== "dispatching" || turn.submissionId !== undefined) {
throw new Error("Turn is not awaiting Flue admission");
}
await ctx.db.patch(turn._id, {
error: undefined,
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "running",
submissionId: args.submissionId,
});
return null;
},
});
export const failTurn = internalMutation({
args: {
attempt: v.number(),
@@ -322,7 +354,7 @@ export const runTurn = internalAction({
if (!flueUrl) {
throw new Error("FLUE_URL is not configured in Convex");
}
const images = await Promise.all(
const attachments = await Promise.all(
turn.attachments.map(async (attachment) => {
const url = await ctx.storage.getUrl(attachment.storageId);
const response = url ? await fetch(url) : null;
@@ -331,6 +363,7 @@ export const runTurn = internalAction({
}
return {
data: toBase64(await response.arrayBuffer()),
filename: attachment.filename,
mimeType: attachment.mimeType,
type: "image" as const,
};
@@ -341,7 +374,11 @@ export const runTurn = internalAction({
`${flueUrl.replace(/\/+$/u, "")}/`
);
const response = await fetch(endpoint, {
body: JSON.stringify({ images, message: turn.user.content }),
body: JSON.stringify({
attachments,
body: turn.user.content,
kind: "user",
}),
headers: {
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
"content-type": "application/json",
+1 -1
View File
@@ -7,7 +7,7 @@ import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { projectConversationRecords } from "./conversationProjections";
const FLUE_SCHEMA_VERSION = "4";
const FLUE_SCHEMA_VERSION = "5";
const DURABILITY_DEFAULT_MAX_ATTEMPTS = 10;
const DURABILITY_DEFAULT_TIMEOUT_MS = 3_600_000;
const LEASE_DURATION_MS = 30_000;
+1 -3
View File
@@ -1,7 +1,5 @@
import { query } from "./_generated/server";
export const get = query({
handler: () =>
"OK"
,
handler: () => "OK",
});
+10 -2
View File
@@ -1,5 +1,13 @@
import { CONTEXT_KINDS, contextPathForKind, MAX_CONTEXT_DOCUMENT_CHARACTERS } from '@code/primitives/project';
import type { PreparedPublicGitSource, PublicGitImportResult, RepositoryContextDocument } from '@code/primitives/project';
import {
CONTEXT_KINDS,
contextPathForKind,
MAX_CONTEXT_DOCUMENT_CHARACTERS,
} from "@code/primitives/project";
import type {
PreparedPublicGitSource,
PublicGitImportResult,
RepositoryContextDocument,
} from "@code/primitives/project";
const GITHUB_HOST = "github.com";
const GITEA_HOST = "git.openputer.com";
+1
View File
@@ -6,6 +6,7 @@
"author": "",
"scripts": {
"dev": "convex dev --env-file ../../.env",
"dev:once": "convex dev --once --env-file=../../.env",
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
"check-types": "tsc --noEmit -p convex/tsconfig.json",
"test": "CONVEX_SITE_URL=https://convex.test FLUE_DB_TOKEN=test-token SITE_URL=https://site.test vitest run",
+3 -3
View File
@@ -7,11 +7,11 @@ const convexUrlSchema = (exampleHost: string) =>
});
export const env = createEnv({
clientPrefix: "EXPO_PUBLIC_",
client: {
EXPO_PUBLIC_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
EXPO_PUBLIC_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"),
EXPO_PUBLIC_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
},
runtimeEnv: process.env,
clientPrefix: "EXPO_PUBLIC_",
emptyStringAsUndefined: true,
runtimeEnv: process.env,
});
+4 -4
View File
@@ -7,17 +7,17 @@ const convexUrlSchema = (exampleHost: string) =>
});
export const env = createEnv({
emptyStringAsUndefined: true,
runtimeEnv: process.env,
server: {
CONVEX_URL: convexUrlSchema("example.convex.cloud"),
DAEMON_COMMAND_LEASE_MS: z.coerce.number().int().positive().default(60_000),
DAEMON_HEARTBEAT_MS: z.coerce.number().int().positive().default(15_000),
DAEMON_ID: z.string().min(1),
DAEMON_NAME: z.string().min(1).optional(),
DAEMON_VERSION: z.string().default("0.0.0"),
DAEMON_HEARTBEAT_MS: z.coerce.number().int().positive().default(15_000),
DAEMON_COMMAND_LEASE_MS: z.coerce.number().int().positive().default(60_000),
FLUE_DB_TOKEN: z.string().min(1),
RIVET_ENDPOINT: z.url().optional(),
},
runtimeEnv: process.env,
skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
emptyStringAsUndefined: true,
});
+35 -22
View File
@@ -2,25 +2,26 @@ import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Button } from "@code/ui/components/button";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-none border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
size: {
default:
"gap-2 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
xs: "gap-1.5 rounded-none text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
},
}
);
function Attachment({
@@ -41,7 +42,7 @@ function Attachment({
data-state={state}
data-size={size}
data-orientation={resolvedOrientation}
className={cn(attachmentVariants({ size, orientation }), className)}
className={cn(attachmentVariants({ orientation, size }), className)}
{...props}
/>
);
@@ -50,6 +51,9 @@ function Attachment({
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-none bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-none group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
defaultVariants: {
variant: "icon",
},
variants: {
variant: {
icon: "",
@@ -57,10 +61,7 @@ const attachmentMediaVariants = cva(
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
},
}
);
function AttachmentMedia({
@@ -78,53 +79,65 @@ function AttachmentMedia({
);
}
function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className,
className
)}
{...props}
/>
);
}
function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className,
className
)}
{...props}
/>
);
}
function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className,
className
)}
{...props}
/>
);
}
function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className,
className
)}
{...props}
/>
@@ -160,10 +173,10 @@ function AttachmentTrigger({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
type: render ? type : (type ?? "button"),
},
props,
props
),
render,
state: {
@@ -178,7 +191,7 @@ function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className,
className
)}
{...props}
/>
+36 -31
View File
@@ -1,7 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -17,28 +18,28 @@ function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
},
},
defaultVariants: {
variant: "default",
},
},
}
);
function Bubble({
@@ -61,17 +62,21 @@ function Bubble({
);
}
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-none border border-transparent px-2.5 py-2 text-xs leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-1 [button,a]:focus-visible:ring-ring/50",
className,
className
),
},
props,
props
),
render,
state: {
@@ -83,21 +88,21 @@ function BubbleContent({ className, render, ...props }: useRender.ComponentProps
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-none bg-muted px-1.5 py-0.5 text-xs ring-2 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
side: "bottom",
},
},
variants: {
align: {
end: "right-3",
start: "left-3",
},
side: {
bottom: "bottom-0 translate-y-3/4",
top: "top-0 -translate-y-3/4",
},
},
}
);
function BubbleReactions({
@@ -114,7 +119,7 @@ function BubbleReactions({
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
className={cn(bubbleReactionsVariants({ align, side }), className)}
{...props}
/>
);
+24 -23
View File
@@ -1,40 +1,41 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-lg": "size-9",
"icon-sm": "size-7 rounded-none",
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
},
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-none",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
}
);
function Button({
@@ -46,7 +47,7 @@ function Button({
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ className, size, variant }))}
{...props}
/>
);
+25 -7
View File
@@ -12,7 +12,7 @@ function Card({
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-none bg-card py-(--card-spacing) text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
className,
className
)}
{...props}
/>
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-none px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
className
)}
{...props}
/>
@@ -38,7 +38,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title"
className={cn(
"cn-font-heading text-sm font-medium group-data-[size=sm]/card:text-sm",
className,
className
)}
{...props}
/>
@@ -59,7 +59,10 @@ 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)}
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
@@ -67,7 +70,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
);
}
@@ -75,10 +82,21 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-none border-t p-(--card-spacing)", className)}
className={cn(
"flex items-center rounded-none border-t p-(--card-spacing)",
className
)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+1 -1
View File
@@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-none border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
className
)}
{...props}
>
+29 -12
View File
@@ -25,7 +25,10 @@ function DropdownMenuContent({
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
@@ -39,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
className={cn(
"cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
className
)}
{...props}
/>
@@ -63,7 +66,10 @@ function DropdownMenuLabel({
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-2 text-xs text-muted-foreground data-inset:pl-7", className)}
className={cn(
"px-2 py-2 text-xs text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
);
@@ -85,7 +91,7 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
className
)}
{...props}
/>
@@ -110,7 +116,7 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
className
)}
{...props}
>
@@ -133,7 +139,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
className={cn(
"cn-menu-target cn-menu-translucent w-auto min-w-[96px] rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
className
)}
align={align}
alignOffset={alignOffset}
@@ -159,7 +165,7 @@ function DropdownMenuCheckboxItem({
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
className
)}
checked={checked}
{...props}
@@ -178,7 +184,12 @@ function DropdownMenuCheckboxItem({
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
@@ -195,7 +206,7 @@ function DropdownMenuRadioItem({
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
className
)}
{...props}
>
@@ -212,7 +223,10 @@ function DropdownMenuRadioItem({
);
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
@@ -222,13 +236,16 @@ function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
className
)}
{...props}
/>
+22 -11
View File
@@ -1,5 +1,6 @@
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -7,7 +8,7 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-none border-dashed p-6 text-center text-balance md:p-12",
className,
className
)}
{...props}
/>
@@ -27,16 +28,16 @@ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-none bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5",
},
},
defaultVariants: {
variant: "default",
},
},
}
);
function EmptyMedia({
@@ -48,7 +49,7 @@ function EmptyMedia({
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
className={cn(emptyMediaVariants({ className, variant }))}
{...props}
/>
);
@@ -58,7 +59,10 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("cn-font-heading text-sm font-medium tracking-tight", className)}
className={cn(
"cn-font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
);
@@ -70,7 +74,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
className
)}
{...props}
/>
@@ -83,11 +87,18 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"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,
className
)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
+32 -32
View File
@@ -1,9 +1,9 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@code/ui/lib/utils"
import { Label } from "@code/ui/components/label"
import { Separator } from "@code/ui/components/separator"
import { Label } from "@code/ui/components/label";
import { Separator } from "@code/ui/components/separator";
import { cn } from "@code/ui/lib/utils";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { useMemo } from "react";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
@@ -15,7 +15,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
)}
{...props}
/>
)
);
}
function FieldLegend({
@@ -33,7 +33,7 @@ function FieldLegend({
)}
{...props}
/>
)
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -46,26 +46,26 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
defaultVariants: {
orientation: "vertical",
},
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
);
function Field({
className,
@@ -80,7 +80,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -93,7 +93,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function FieldLabel({
@@ -110,7 +110,7 @@ function FieldLabel({
)}
{...props}
/>
)
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -123,7 +123,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -138,7 +138,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
)}
{...props}
/>
)
);
}
function FieldSeparator({
@@ -146,7 +146,7 @@ function FieldSeparator({
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
children?: React.ReactNode;
}) {
return (
<div
@@ -168,7 +168,7 @@ function FieldSeparator({
</span>
)}
</div>
)
);
}
function FieldError({
@@ -177,23 +177,23 @@ function FieldError({
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
errors?: ({ message?: string } | undefined)[];
}) {
const content = useMemo(() => {
if (children) {
return children
return children;
}
if (!errors?.length) {
return null
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
@@ -203,11 +203,11 @@ function FieldError({
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
);
}, [children, errors]);
if (!content) {
return null
return null;
}
return (
@@ -219,7 +219,7 @@ function FieldError({
>
{content}
</div>
)
);
}
export {
@@ -233,4 +233,4 @@ export {
FieldSet,
FieldContent,
FieldTitle,
}
};
+45 -31
View File
@@ -4,7 +4,8 @@ import { Button } from "@code/ui/components/button";
import { Input } from "@code/ui/components/input";
import { Textarea } from "@code/ui/components/textarea";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -19,7 +20,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-1 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
className
)}
{...props}
/>
@@ -29,20 +30,22 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-xs font-medium text-muted-foreground group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-none [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
variants: {
align: {
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
},
},
}
);
function InputGroupAddon({
@@ -61,7 +64,9 @@ function InputGroupAddon({
return;
}
e.currentTarget.parentElement
?.querySelector<HTMLInputElement | HTMLTextAreaElement>("input, textarea")
?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"input, textarea"
)
?.focus();
}}
{...props}
@@ -69,19 +74,22 @@ function InputGroupAddon({
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-xs shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-7 gap-1 rounded-none px-2",
"icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0",
"icon-sm": "size-7 rounded-none p-0 has-[>svg]:p-0",
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-xs shadow-none",
{
defaultVariants: {
size: "xs",
},
},
defaultVariants: {
size: "xs",
},
});
variants: {
size: {
"icon-sm": "size-7 rounded-none p-0 has-[>svg]:p-0",
"icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0",
sm: "h-7 gap-1 rounded-none px-2",
xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
},
},
}
);
function InputGroupButton({
className,
@@ -109,33 +117,39 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
<span
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
className
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
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 ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
className
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
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-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
className
)}
{...props}
/>
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
className
)}
{...props}
/>
+1 -1
View File
@@ -9,7 +9,7 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
data-slot="label"
className={cn(
"flex items-center gap-2 text-xs leading-none 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,
className
)}
{...props}
/>
+11 -7
View File
@@ -1,7 +1,8 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
const markerVariants = cva(
@@ -9,13 +10,13 @@ const markerVariants = cva(
{
variants: {
variant: {
border: "border-b border-border pb-2",
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
},
}
);
function Marker({
@@ -28,9 +29,9 @@ function Marker({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
className: cn(markerVariants({ className, variant })),
},
props,
props
),
render,
state: {
@@ -45,7 +46,10 @@ function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn("size-3.5 shrink-0 [&_svg:not([class*='size-'])]:size-3.5", className)}
className={cn(
"size-3.5 shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
/>
);
@@ -57,7 +61,7 @@ function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
className
)}
{...props}
/>
+14 -13
View File
@@ -2,17 +2,18 @@
import { Button } from "@code/ui/components/button";
import { cn } from "@code/ui/lib/utils";
import {
MessageScroller as MessageScrollerPrimitive,
import { MessageScroller as MessageScrollerPrimitive } from "@shadcn/react/message-scroller";
import { ArrowDownIcon } from "lucide-react";
import * as React from "react";
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller";
import { ArrowDownIcon } from "lucide-react";
import * as React from "react";
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
@@ -26,7 +27,7 @@ function MessageScroller({
data-slot="message-scroller"
className={cn(
"cn-message-scroller group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className,
className
)}
{...props}
/>
@@ -42,7 +43,7 @@ function MessageScrollerViewport({
data-slot="message-scroller-viewport"
className={cn(
"cn-message-scroller-viewport size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-none",
className,
className
)}
{...props}
/>
@@ -56,7 +57,10 @@ function MessageScrollerContent({
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("cn-message-scroller-content flex h-max min-h-full flex-col gap-6", className)}
className={cn(
"cn-message-scroller-content flex h-max min-h-full flex-col gap-6",
className
)}
{...props}
/>
);
@@ -73,7 +77,7 @@ function MessageScrollerItem({
scrollAnchor={scrollAnchor}
className={cn(
"cn-message-scroller-item min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className,
className
)}
{...props}
/>
@@ -99,7 +103,7 @@ function MessageScrollerButton({
direction={direction}
className={cn(
"cn-message-scroller-button absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className,
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
@@ -123,7 +127,4 @@ export {
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
+13 -6
View File
@@ -22,7 +22,7 @@ function Message({
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-1.5 text-xs data-[align=end]:flex-row-reverse",
className,
className
)}
{...props}
/>
@@ -35,7 +35,7 @@ function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className,
className
)}
{...props}
/>
@@ -48,7 +48,7 @@ function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className,
className
)}
{...props}
/>
@@ -61,7 +61,7 @@ function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-2.5 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className,
className
)}
{...props}
/>
@@ -74,11 +74,18 @@ function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-2.5 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className,
className
)}
{...props}
/>
);
}
export { MessageGroup, Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader };
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
};
+5 -6
View File
@@ -1,8 +1,7 @@
"use client"
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@code/ui/lib/utils"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@code/ui/lib/utils";
function Separator({
className,
@@ -19,7 +18,7 @@ function Separator({
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };
+8 -7
View File
@@ -8,7 +8,8 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { Toaster as Sonner } from "sonner";
import type { ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
@@ -18,18 +19,18 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
success: <CircleCheckIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
"--normal-bg": "var(--popover)",
"--normal-border": "var(--border)",
"--normal-text": "var(--popover-foreground)",
} as React.CSSProperties
}
toastOptions={{
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full resize-none rounded-none border border-input bg-transparent px-2.5 py-2 text-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
className
)}
{...props}
/>
+16 -4
View File
@@ -3,8 +3,17 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@code/ui/lib/utils";
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
);
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
@@ -24,7 +33,10 @@ function TooltipContent({
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
@@ -38,7 +50,7 @@ function TooltipContent({
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-none bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
className
)}
{...props}
>
+3 -4
View File
@@ -1,6 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));