1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { cva, type VariantProps } from "class-variance-authority";
4import type * as React from "react";
5
6import { cn } from "~/lib/utils";
7
8/**
9 * The inline pill for one piece of chat context. It lives with the chat components rather than
10 * in components/ui because it is one feature's look, not a generic primitive.
11 *
12 * An inline pill for one piece of context (a file, mention, terminal excerpt, pull request,
13 * skill…) that sits in running text, both in the composer and in sent messages.
14 *
15 * Metrics are in em so the chip scales with the text around it (the composer honors the
16 * prompt font-size preference). Each kind keeps one restrained color identity: every accent
17 * shares a lightness so no kind reads heavier than another, and only hue carries identity.
18 * A consumer may override `--context-chip-accent` through `style` for a color that comes
19 * from content, such as an image's average color.
20 *
21 * Renders a span by default. Render it as a button, link or popover/tooltip trigger to make it
22 * interactive; the hover tint follows from that, not from a prop. Anything focusable (including a
23 * span with tabIndex for a tooltip) gets the focus outline.
24 */
25const contextChipVariants = cva(
26 "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default",
27 {
28 defaultVariants: { kind: "neutral" },
29 variants: {
30 kind: {
31 neutral: "border-border/70 bg-accent/40 text-foreground",
32 image: "[--context-chip-accent:oklch(0.62_0.16_16)]",
33 video: "[--context-chip-accent:oklch(0.62_0.16_48)]",
34 file: "[--context-chip-accent:oklch(0.62_0.136_237)]",
35 mention: "[--context-chip-accent:oklch(0.62_0.11_215)]",
36 terminal: "[--context-chip-accent:oklch(0.62_0.134_163)]",
37 element: "[--context-chip-accent:oklch(0.62_0.134_70)]",
38 "preview-annotation": "[--context-chip-accent:oklch(0.62_0.134_70)]",
39 "review-comment": "[--context-chip-accent:oklch(0.62_0.16_292)]",
40 "pull-request": "[--context-chip-accent:oklch(0.62_0.16_277)]",
41 "pr-open": "[--context-chip-accent:oklch(0.62_0.134_163)]",
42 "pr-draft": "[--context-chip-accent:oklch(0.62_0.02_259)]",
43 "pr-merged": "[--context-chip-accent:oklch(0.62_0.16_292)]",
44 "pr-closed": "[--context-chip-accent:oklch(0.62_0.16_16)]",
45 skill: "[--context-chip-accent:oklch(0.62_0.16_322)]",
46 thread: "[--context-chip-accent:oklch(0.62_0.12_190)]",
47 citation: "[--context-chip-accent:oklch(0.62_0.16_259)]",
48 },
49 // Colors live in compoundVariants below so they come after the kind colors.
50 state: {
51 unresolved: "border-dashed",
52 invalid: "",
53 },
54 },
55 compoundVariants: [
56 {
57 kind: [
58 "image",
59 "video",
60 "file",
61 "mention",
62 "terminal",
63 "element",
64 "preview-annotation",
65 "review-comment",
66 "pull-request",
67 "pr-open",
68 "pr-draft",
69 "pr-merged",
70 "pr-closed",
71 "skill",
72 "thread",
73 "citation",
74 ],
75 className:
76 "[--context-chip-border:color-mix(in_oklab,var(--context-chip-accent)_34%,var(--contrast-border))] [--context-chip-border-hover:color-mix(in_oklab,var(--context-chip-accent)_48%,var(--contrast-border))] [--context-chip-foreground:color-mix(in_oklab,var(--context-chip-accent)_22%,var(--contrast-foreground))] border-(--context-chip-border) bg-(--context-chip-accent)/11 text-(--context-chip-foreground) [button:enabled&,a&]:hover:border-(--context-chip-border-hover) [button:enabled&,a&]:hover:bg-(--context-chip-accent)/17",
77 },
78 // State colors win over any kind.
79 { state: "unresolved", className: "text-foreground" },
80 { state: "invalid", className: "border-destructive/35 bg-destructive/8 text-destructive" },
81 ],
82 },
83);
84
85type ContextChipKind = NonNullable<VariantProps<typeof contextChipVariants>["kind"]>;
86
87interface ContextChipProps extends useRender.ComponentProps<"span"> {
88 kind?: ContextChipKind;
89 state?: "unresolved" | "invalid";
90}
91
92function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) {
93 const defaultProps = {
94 className: cn(contextChipVariants({ kind, state }), className),
95 "data-slot": "context-chip",
96 "data-state": state,
97 };
98 return useRender({
99 defaultTagName: "span",
100 props: mergeProps<"span">(defaultProps, props),
101 render,
102 });
103}
104
105/** The chip's text. Truncates to the chip's width. */
106function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) {
107 return (
108 <span
109 className={cn("block min-w-0 self-center truncate leading-tight", className)}
110 data-slot="context-chip-label"
111 {...props}
112 />
113 );
114}
115
116/** An icon action inside a chip, such as editing a citation's comment. Tints with the chip's kind. */
117function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
118 const defaultProps = {
119 className: cn(
120 "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]",
121 className,
122 ),
123 "data-slot": "context-chip-action",
124 type: render ? undefined : ("button" as const),
125 };
126 return useRender({
127 defaultTagName: "button",
128 props: mergeProps<"button">(defaultProps, props),
129 render,
130 });
131}
132
133export { ContextChip, ContextChipAction, ContextChipLabel, type ContextChipKind };