Composer

AI composer with attachments, modes, model controls, and working states.

Composer

Attachments, modes, and model controls.

function ComposerDemo() {
  return <Composer className="w-full max-w-[560px]" />;
}
function ComposerDemo() {
  return <Composer className="w-full max-w-[560px]" />;
}

With status

The composer plus its status bar.

57%
function ComposerPreviewDemo() {
  return <AiChatComposerPreview />;
}
function ComposerPreviewDemo() {
  return <AiChatComposerPreview />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/composer.json
npx shadcn@latest add https://boardcn.dev/r/composer.json

npm packages

  • @remixicon/react

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/blocks/composer/composer.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type FormEvent,
  type HTMLAttributes,
  type KeyboardEvent,
  type ReactNode,
  type Ref,
} from "react";
import {
  RiAddLine,
  RiArrowDownSLine,
  RiArrowUpLine,
  RiAttachment2,
  RiCheckLine,
  RiCodeSSlashLine,
  RiFileExcel2Line,
  RiFileTextLine,
  RiFocus3Line,
  RiFolderLine,
  RiGitBranchLine,
  RiInfinityLine,
  RiListCheck3,
  RiMicLine,
  RiSlideshowLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import {
  Dropdown,
  DropdownDivider,
  DropdownGroup,
  DropdownItem,
  DropdownPopover,
  DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { ComposerLoader } from "@/components/blocks/composer-loader/composer-loader";
import { cx } from "@/utils/cx";

/** Iridescent rim gradient for the working-state loader (UI chrome, not sample data). */
const COMPOSER_LOADER_COLORS: [string, string, string, string] = [
  "#5eead4",
  "#46baec",
  "#e633a4",
  "#00faa7",
];

export type ComposerModel = "Composer 2.5" | "GPT-5.6 Sol" | "Fable 5" | "Sonnet 5";
export type ComposerAgentMode = "Agent" | "Ask" | "Plan";
export type ComposerAttachmentAction = "files" | "goal" | "plan" | "documents" | "spreadsheets" | "presentations" | "code";
export type ComposerMenu = "attachments" | "model" | null;
export type StatusBarMenu = "project" | "mode" | null;

export interface ComposerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSubmit" | "onChange"> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onSubmit?: (value: string, files: File[]) => void | Promise<void>;
  placeholder?: string;
  model?: ComposerModel;
  defaultModel?: ComposerModel;
  onModelChange?: (model: ComposerModel) => void;
  effort?: number;
  defaultEffort?: number;
  onEffortChange?: (effort: number) => void;
  files?: File[];
  onFilesChange?: (files: File[]) => void;
  onAttachmentAction?: (action: ComposerAttachmentAction) => void;
  openMenu?: ComposerMenu;
  defaultOpenMenu?: ComposerMenu;
  onOpenMenuChange?: (menu: ComposerMenu) => void;
  working?: boolean;
  disabled?: boolean;
  voiceActive?: boolean;
  onVoiceActiveChange?: (active: boolean) => void;
  ref?: Ref<HTMLDivElement>;
}

export interface StatusBarProps extends HTMLAttributes<HTMLDivElement> {
  branch?: string;
  onBranchAction?: (branch: string) => void;
  project?: string;
  projects?: string[];
  onProjectChange?: (project: string) => void;
  mode?: ComposerAgentMode;
  onModeChange?: (mode: ComposerAgentMode) => void;
  contextUsage?: number;
  openMenu?: StatusBarMenu;
  defaultOpenMenu?: StatusBarMenu;
  onOpenMenuChange?: (menu: StatusBarMenu) => void;
  ref?: Ref<HTMLDivElement>;
}

export interface AiChatComposerPreviewProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSubmit"> {
  composerProps?: ComposerProps;
  statusBarProps?: StatusBarProps;
  onSubmit?: ComposerProps["onSubmit"];
  working?: boolean;
  ref?: Ref<HTMLDivElement>;
}

export interface GlassComposerProps extends HTMLAttributes<HTMLDivElement> {
  glass?: boolean;
  children: ReactNode;
  ref?: Ref<HTMLDivElement>;
}

const MODELS: ComposerModel[] = ["Composer 2.5", "GPT-5.6 Sol", "Fable 5", "Sonnet 5"];
const EFFORT_LABELS = ["Minimal", "Medium", "High", "Max", "Extended", "Ultra"];

/** Ultra effort jet: a purple flame trailing left off the thumb. Same raw-WebGL
 *  recipe as PlanArtFlame — fullscreen quad, rAF loop, CSS fallback. */
const EFFORT_FLAME_VERT = /* glsl */ `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
  v_uv = a_pos * 0.5 + 0.5;
  gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;

const EFFORT_FLAME_FRAG = /* glsl */ `
precision mediump float;
uniform float u_time;
uniform vec2 u_res;
varying vec2 v_uv;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p = p * 2.03 + vec2(17.0, 9.2);
    a *= 0.5;
  }
  return v;
}

float lobe(vec2 uv, float yOff, float tOff, float time) {
  // Scroll noise to the left so the jet reads as exhaust off the thumb.
  float n = fbm(vec2(uv.x * 5.4 + time * 2.35 + tOff, uv.y * 4.2 + time * 0.45));
  float n2 = fbm(vec2(uv.x * 9.0 - time * 3.1 + tOff, uv.y * 7.0));
  float tail = 1.0 - uv.x;
  float mid = 0.5 + yOff + (n - 0.5) * mix(0.06, 0.32, tail);
  float w = mix(0.52, 0.045, pow(tail, 0.62));
  w *= 0.72 + 0.4 * n2;
  float d = abs(uv.y - mid) / max(w, 0.008);
  d += (n2 - 0.5) * 0.45;
  float body = 1.0 - smoothstep(0.05, 1.05, d);
  body *= pow(uv.x, 0.28);
  body *= smoothstep(0.0, 0.1, uv.x);
  return body;
}

void main() {
  vec2 uv = v_uv;
  float time = u_time;

  float a = lobe(uv, 0.0, 0.0, time);
  float b = lobe(uv, 0.07, 4.7, time * 1.08);
  float c = lobe(uv, -0.06, 9.1, time * 0.92);
  float heat = max(a, max(b * 0.88, c * 0.82));
  heat += a * b * 0.18;
  heat *= 0.86 + 0.14 * sin(time * 21.0 + heat * 9.0);

  vec3 deep = vec3(0.26, 0.05, 0.48);
  vec3 midc = vec3(0.64, 0.24, 0.96);
  vec3 hot = vec3(0.9, 0.72, 1.0);
  vec3 core = vec3(1.0, 0.95, 1.0);

  float coreMask = heat * heat * smoothstep(0.42, 0.95, uv.x) * (1.0 - abs(uv.y - 0.5) * 1.6);
  vec3 col = mix(deep, midc, clamp(heat * 1.35, 0.0, 1.0));
  col = mix(col, hot, clamp(heat * heat * 1.7, 0.0, 1.0));
  col = mix(col, core, clamp(coreMask, 0.0, 1.0));

  float alpha = clamp(heat * 1.4, 0.0, 1.0);

  // White-lilac sparks in the wake, elongated and drifting left.
  for (int i = 0; i < 4; i++) {
    float fi = float(i);
    float sx = 7.5 + fi * 4.5;
    float sy = 4.4 + fi * 1.6;
    vec2 sp = vec2(uv.x * sx + time * (1.55 + fi * 0.45), uv.y * sy);
    vec2 cell = floor(sp);
    float sh = hash(cell + fi * 19.1);
    if (sh > 0.58) {
      vec2 pos = vec2(0.3 + 0.45 * hash(cell + 2.2), 0.22 + 0.56 * hash(cell + 7.7));
      vec2 delta = fract(sp) - pos;
      float spark = smoothstep(0.2, 0.03, length(delta * vec2(0.38, 3.1)));
      float life = fract(time * (0.38 + sh * 0.4) + sh * 5.0);
      float glow = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.5, 0.95, life));
      float gate = smoothstep(0.08, 0.45, heat + 0.15) * smoothstep(0.05, 0.35, uv.x);
      float lum = spark * glow * gate * (0.75 + 0.25 * sh);
      col += vec3(1.0, 0.9, 1.0) * lum;
      alpha = max(alpha, lum);
    }
  }

  gl_FragColor = vec4(col, alpha);
}
`;

function EffortFlame() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setFailed(true);
      return;
    }

    const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false });
    if (!gl) {
      setFailed(true);
      return;
    }

    const compile = (type: number, source: string) => {
      const shader = gl.createShader(type)!;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
    };
    const vert = compile(gl.VERTEX_SHADER, EFFORT_FLAME_VERT);
    const frag = compile(gl.FRAGMENT_SHADER, EFFORT_FLAME_FRAG);
    if (!vert || !frag) {
      setFailed(true);
      return;
    }
    const program = gl.createProgram()!;
    gl.attachShader(program, vert);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      setFailed(true);
      return;
    }
    gl.useProgram(program);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const aPos = gl.getAttribLocation(program, "a_pos");
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    const uTime = gl.getUniformLocation(program, "u_time");
    const uRes = gl.getUniformLocation(program, "u_res");

    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    const host = canvas.parentElement;
    const fit = () => {
      const box = (host ?? canvas).getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = Math.max(1, Math.floor(box.width * dpr));
      canvas.height = Math.max(1, Math.floor(box.height * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uRes, canvas.width, canvas.height);
    };
    fit();
    const observer = new ResizeObserver(fit);
    observer.observe(host ?? canvas);

    let raf = 0;
    const start = performance.now();
    const draw = (now: number) => {
      gl.uniform1f(uTime, (now - start) / 1000);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, []);

  if (failed) {
    return <span aria-hidden className="bui-effort-ultra-flame-static" />;
  }
  return <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />;
}

function useControllable<T>(controlled: T | undefined, initial: T, onChange?: (value: T) => void) {
  const [internal, setInternal] = useState(initial);
  const value = controlled ?? internal;
  const setValue = (next: T) => {
    if (controlled === undefined) setInternal(next);
    onChange?.(next);
  };
  return [value, setValue] as const;
}

function MenuText({ title, detail }: { title: string; detail?: string }) {
  return (
    <span className="min-w-0 truncate text-body-medium whitespace-nowrap">
      <span className="text-text-primary">{title}</span>
      {detail ? <span className="ml-1.5 text-text-secondary">{detail}</span> : null}
    </span>
  );
}

function PluginIcon({ tone, children }: { tone: string; children: ReactNode }) {
  return (
    <span className={cx("flex size-6 shrink-0 items-center justify-center rounded-md text-white shadow-xs", tone)}>
      {children}
    </span>
  );
}

function SelectionDot({ selected }: { selected: boolean }) {
  return (
    <span
      aria-hidden
      className={cx(
        "relative flex size-3.5 shrink-0 items-center justify-center rounded-full border shadow-xs transition-colors duration-200",
        selected
          ? "border-accent-500 bg-gradient-to-b from-accent-500 to-accent-600"
          : "border-border-checkbox-default bg-background-primary-default",
      )}
    >
      <span className={cx("size-[5px] rounded-full bg-control-indicator-background transition-[scale,opacity] duration-200", selected ? "scale-100 opacity-100" : "scale-0 opacity-0")} />
    </span>
  );
}

function AttachmentMenu({ onAction, onFiles, onClose }: { onAction?: (action: ComposerAttachmentAction) => void; onFiles: () => void; onClose: () => void }) {
  const select = (action: ComposerAttachmentAction) => {
    if (action === "files") onFiles();
    onAction?.(action);
    onClose();
  };
  return (
    <DropdownPopover aria-label="Add to chat" placement="top start" offset={8} className="w-[361px] max-w-[calc(100vw-32px)] p-2" dialogClassName="gap-2">
      <DropdownGroup label="Add">
        <DropdownItem className="py-1.5" onSelect={() => select("files")}><RiAttachment2 className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Files and folders" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("goal")}><RiFocus3Line className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Goal" detail="Set a goal for faster results" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("plan")}><RiListCheck3 className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Plan mode" detail="Manage complex tasks" /></DropdownItem>
      </DropdownGroup>
      <DropdownGroup label="Plugins">
        <DropdownItem className="py-1.5" onSelect={() => select("documents")}><PluginIcon tone="bg-blue-500"><RiFileTextLine className="size-4" /></PluginIcon><MenuText title="Documents" detail="Create and edit documents" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("spreadsheets")}><PluginIcon tone="bg-emerald-500"><RiFileExcel2Line className="size-4" /></PluginIcon><MenuText title="Spreadsheets" detail="Generate spreadsheets" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("presentations")}><PluginIcon tone="bg-orange-500"><RiSlideshowLine className="size-4" /></PluginIcon><MenuText title="Presentations" detail="Create marketing assets" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("code")}><PluginIcon tone="bg-violet-500"><RiCodeSSlashLine className="size-4" /></PluginIcon><MenuText title="Code blocks" detail="Write and edit existing code" /></DropdownItem>
      </DropdownGroup>
    </DropdownPopover>
  );
}

function ModelMenu({ model, setModel, effort, setEffort }: { model: ComposerModel; setModel: (model: ComposerModel) => void; effort: number; setEffort: (effort: number) => void }) {
  const clampedEffort = Math.min(5, Math.max(0, effort));
  const isUltra = clampedEffort === 5;
  return (
    <DropdownPopover aria-label="Model settings" placement="top end" offset={8} className="w-[266px] max-w-[calc(100vw-32px)]">
      <DropdownGroup label="Models">
        {MODELS.map((item) => (
          <DropdownItem key={item} selected={item === model} onSelect={() => setModel(item)} className="justify-between p-2" >
            <span className="truncate text-body-medium whitespace-nowrap text-text-primary">{item}</span>
            <SelectionDot selected={item === model} />
          </DropdownItem>
        ))}
      </DropdownGroup>
      <DropdownDivider className="mt-[7px] mb-3" />
      <div className="flex w-full flex-col">
        <span className="pl-2 text-body-medium text-text-secondary">Effort <span className="text-text-primary">{EFFORT_LABELS[clampedEffort]}</span></span>
        <div className="flex items-center justify-between px-2 pt-2 pb-[3px] text-body-2-medium text-text-secondary"><span>Faster</span><span>Smarter</span></div>
        <div className="px-2 pb-2">
          <div className={cx("relative", isUltra && "bui-effort-ultra")}>
            <div className="relative h-[27px] overflow-hidden rounded-lg bg-background-secondary-default">
              <span
                className={cx(
                  "absolute overflow-hidden rounded-lg bg-background-tertiary-hover transition-[width,background-color] duration-150 ease-out",
                  isUltra ? "inset-0 bui-effort-ultra-rail" : "inset-y-0 left-0",
                )}
                style={isUltra ? undefined : { width: `calc(${clampedEffort * 20}% + 16.8px)` }}
              >
                {isUltra ? <EffortFlame /> : null}
              </span>
              <span className={cx("pointer-events-none absolute inset-x-[9px] top-[7px] flex h-[13px] items-center justify-between transition-opacity duration-150", isUltra && "opacity-0")}>
                {EFFORT_LABELS.map((label, index) => <span key={label} className={cx("h-full w-[3px] rounded-[2px] bg-foreground-icon-tertiary", index <= clampedEffort ? "opacity-100" : "opacity-30")} />)}
              </span>
              <input aria-label="Effort" type="range" min={0} max={5} step={1} value={clampedEffort} onChange={(event) => setEffort(Number(event.currentTarget.value))} className="absolute inset-0 size-full cursor-grab opacity-0 active:cursor-grabbing" />
              <span aria-hidden className="pointer-events-none absolute top-0 z-10 h-[27px] w-[21px] -translate-x-1/2 rounded-[7px] border border-border-checkbox-default bg-background-primary-default shadow-xs transition-[left] duration-150 ease-out" style={{ left: `calc(10.5px + (100% - 21px) * ${clampedEffort / 5})` }} />
            </div>
          </div>
        </div>
      </div>
    </DropdownPopover>
  );
}

export function GlassComposer({ glass = false, className, children, ref, ...props }: GlassComposerProps) {
  return (
    <div ref={ref} className={cx("relative", className)} data-glass={glass || undefined} {...props}>
      <span aria-hidden className={cx("pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-[480ms]", glass && "opacity-100")}>
        <span className="absolute inset-y-2 left-2 size-9 rounded-full border border-white/40 bg-white/20 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_6px_24px_rgba(34,114,255,.14)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
        <span className="absolute top-2.5 right-[104px] h-8 w-[84px] rounded-xl border border-white/35 bg-white/15 shadow-[inset_0_1px_0_rgba(255,255,255,.45)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
        <span className="absolute inset-y-2 right-[52px] size-9 rounded-full border border-white/40 bg-white/20 shadow-[inset_0_1px_0_rgba(255,255,255,.55)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
      </span>
      <div className={cx("relative transition-colors duration-[480ms]", glass && "[&_button:not([aria-label='Send_message'])]:!border-transparent [&_button:not([aria-label='Send_message'])]:!bg-transparent [&_button:not([aria-label='Send_message'])]:!shadow-none")}>{children}</div>
    </div>
  );
}

export function Composer({
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  onSubmit,
  placeholder = "Ask me anything",
  model: controlledModel,
  defaultModel = "Fable 5",
  onModelChange,
  effort: controlledEffort,
  defaultEffort = 1,
  onEffortChange,
  files: controlledFiles,
  onFilesChange,
  onAttachmentAction,
  openMenu: controlledOpenMenu,
  defaultOpenMenu = null,
  onOpenMenuChange,
  working: controlledWorking,
  disabled = false,
  voiceActive = false,
  onVoiceActiveChange,
  className,
  ref,
  ...props
}: ComposerProps) {
  const [value, setValue] = useControllable(controlledValue, defaultValue, onValueChange);
  const [model, setModel] = useControllable(controlledModel, defaultModel, onModelChange);
  const [effort, setEffort] = useControllable(controlledEffort, defaultEffort, onEffortChange);
  const [files, setFiles] = useControllable(controlledFiles, [], onFilesChange);
  const [openMenu, setOpenMenu] = useControllable(controlledOpenMenu, defaultOpenMenu, onOpenMenuChange);
  const [internalVoiceActive, setInternalVoiceActive] = useState(voiceActive);
  const [internalWorking, setInternalWorking] = useState(false);
  const fileInput = useRef<HTMLInputElement>(null);
  const working = controlledWorking ?? internalWorking;
  const currentVoiceActive = onVoiceActiveChange ? voiceActive : internalVoiceActive;
  const blocked = disabled || working;

  const submit = async () => {
    const message = value.trim();
    if (blocked || (!message && files.length === 0)) return;
    const result = onSubmit?.(message, files);
    if (result instanceof Promise && controlledWorking === undefined) {
      setInternalWorking(true);
      try { await result; } finally { setInternalWorking(false); }
    }
    if (controlledValue === undefined) setValue("");
    if (controlledFiles === undefined) setFiles([]);
  };

  const onFormSubmit = (event: FormEvent) => { event.preventDefault(); void submit(); };
  const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      void submit();
    }
  };
  const onFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selected = Array.from(event.currentTarget.files ?? []);
    event.currentTarget.value = "";
    if (selected.length) setFiles([...files, ...selected]);
  };

  return (
    <div ref={ref} className={cx("relative", className)} {...props}>
      <span aria-hidden className="absolute inset-0 rounded-full bg-black opacity-10 blur-[50px] dark:hidden" />
      <ComposerLoader active={working} surface={false} colors={COMPOSER_LOADER_COLORS}>
        <GlassComposer glass={working}>
          <form onSubmit={onFormSubmit} className="relative flex min-h-[52px] w-full items-center gap-2.5 rounded-full bg-background-primary-default py-2 pr-2 pl-2 shadow-xs">
            <input ref={fileInput} type="file" multiple className="sr-only" onChange={onFileChange} />
            <Dropdown isOpen={openMenu === "attachments"} onOpenChange={(open) => setOpenMenu(open ? "attachments" : null)}>
              <DropdownTrigger aria-label="Add attachment" isDisabled={blocked} className="flex size-9 shrink-0 items-center justify-center rounded-full bg-ai-chat-composer-add-background p-2 transition-colors duration-150 ease hover:bg-ai-chat-composer-add-hover-background">
                <RiAddLine className={cx("size-5 text-foreground-icon-primary transition-transform duration-200 ease", openMenu === "attachments" && "rotate-45")} aria-hidden />
              </DropdownTrigger>
              <AttachmentMenu onAction={onAttachmentAction} onFiles={() => fileInput.current?.click()} onClose={() => setOpenMenu(null)} />
            </Dropdown>

            <input aria-label="Message" placeholder={placeholder} value={value} disabled={blocked} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={onInputKeyDown} className="h-5 min-w-0 flex-1 bg-transparent text-body-regular text-text-primary caret-accent-500 outline-none placeholder:text-text-tertiary disabled:cursor-not-allowed" />

            <Dropdown isOpen={openMenu === "model"} onOpenChange={(open) => setOpenMenu(open ? "model" : null)}>
              <DropdownTrigger isDisabled={blocked} className="flex h-8 max-w-[8.5rem] shrink-0 items-center gap-1 rounded-xl bg-background-primary-default py-1.5 pr-1 pl-2 transition-colors duration-150 ease hover:bg-background-primary-hover">
                <span className="min-w-0 truncate px-0.5 text-body-medium text-text-secondary">{model}</span>
                <RiArrowDownSLine className={cx("size-[18px] shrink-0 text-foreground-icon-secondary transition-transform duration-200 ease", openMenu === "model" && "rotate-180")} aria-hidden />
              </DropdownTrigger>
              <ModelMenu model={model} setModel={setModel} effort={effort} setEffort={setEffort} />
            </Dropdown>

            <div className="flex shrink-0 items-center gap-2 pl-1.5">
              <Button type="button" variant="secondary" iconOnly leadingIcon={RiMicLine} aria-label="Voice input" aria-pressed={currentVoiceActive} disabled={blocked} onClick={() => { const next = !currentVoiceActive; setInternalVoiceActive(next); onVoiceActiveChange?.(next); }} className={cx("rounded-full", currentVoiceActive && "border-accent-500 bg-accent-50 text-accent-600 dark:bg-accent-950")} />
              <Button type="submit" iconOnly leadingIcon={RiArrowUpLine} aria-label="Send message" disabled={blocked} className="rounded-full disabled:opacity-50" />
            </div>
          </form>
        </GlassComposer>
      </ComposerLoader>
      {files.length ? (
        <div className="absolute bottom-[58px] left-2 flex max-w-[calc(100%-16px)] gap-1 overflow-x-auto" aria-label="Attachments">
          {files.map((file, index) => (
            <button key={`${file.name}-${file.lastModified}-${index}`} type="button" onClick={() => setFiles(files.filter((_, fileIndex) => fileIndex !== index))} className="flex h-7 shrink-0 items-center gap-1 rounded-lg border border-border-button-default bg-background-primary-default px-2 text-body-2-medium text-text-secondary shadow-xs" aria-label={`Remove ${file.name}`}>
              <RiAttachment2 className="size-3.5" aria-hidden /><span className="max-w-32 truncate">{file.name}</span>
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

function StatusControl({ icon: Icon, children }: { icon: typeof RiFolderLine; children: ReactNode }) {
  return <span className="flex min-w-0 items-center gap-1"><Icon className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden />{children}</span>;
}

export function StatusBar({
  branch = "Main",
  onBranchAction,
  project: controlledProject,
  projects = ["users/mertcan/project-sea", "users/desktop/vibl", "users/documents/boardcn"],
  onProjectChange,
  mode: controlledMode,
  onModeChange,
  contextUsage = 57,
  openMenu: controlledOpenMenu,
  defaultOpenMenu = null,
  onOpenMenuChange,
  className,
  ref,
  ...props
}: StatusBarProps) {
  const defaultProject = controlledProject ?? projects[0]?.split("/").at(-1) ?? "project-sea";
  const [project, setProject] = useControllable(controlledProject, defaultProject, onProjectChange);
  const [mode, setMode] = useControllable(controlledMode, "Agent", onModeChange);
  const [openMenu, setOpenMenu] = useControllable(controlledOpenMenu, defaultOpenMenu, onOpenMenuChange);
  const usage = Math.min(100, Math.max(0, contextUsage));
  const circumference = 2 * Math.PI * 6;

  return (
    <div ref={ref} className={cx("flex h-[26px] w-full items-center justify-between gap-3", className)} {...props}>
      <div className="flex min-w-0 items-center gap-3">
        <button type="button" onClick={() => onBranchAction?.(branch)} className="flex shrink-0 cursor-pointer items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiGitBranchLine className="size-4 shrink-0 -scale-y-100 text-foreground-icon-secondary" aria-hidden /><span className="text-body-2-medium whitespace-nowrap text-text-secondary">{branch}</span></button>
        <Dropdown isOpen={openMenu === "project"} onOpenChange={(open) => setOpenMenu(open ? "project" : null)}>
          <DropdownTrigger className="flex min-w-0 items-center gap-1 rounded-sm"><RiFolderLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex min-w-0 items-center"><span className="max-w-32 truncate text-body-2-medium whitespace-nowrap text-text-secondary">{project}</span><RiArrowDownSLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /></span></DropdownTrigger>
          <DropdownPopover aria-label="Local folders" placement="top start" className="w-[290px]">
            <DropdownGroup label="Local Folders">{projects.map((path) => { const name = path.split("/").at(-1) ?? path; return <DropdownItem key={path} selected={name === project} onSelect={() => { setProject(name); setOpenMenu(null); }} className="justify-between"><span className="truncate text-body-medium text-text-primary">{path}</span>{name === project ? <RiCheckLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /> : null}</DropdownItem>; })}</DropdownGroup>
          </DropdownPopover>
        </Dropdown>
      </div>
      <div className="flex shrink-0 items-center gap-3">
        <Dropdown isOpen={openMenu === "mode"} onOpenChange={(open) => setOpenMenu(open ? "mode" : null)}>
          <DropdownTrigger className="flex items-center gap-1 rounded-sm"><RiInfinityLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center"><span className="text-body-2-medium whitespace-nowrap text-text-secondary">{mode}</span><RiArrowDownSLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /></span></DropdownTrigger>
          <DropdownPopover aria-label="Agent mode" placement="top end" className="w-[210px]">
            <DropdownGroup label="Mode">{(["Agent", "Ask", "Plan"] as ComposerAgentMode[]).map((item) => <DropdownItem key={item} selected={item === mode} onSelect={() => { setMode(item); setOpenMenu(null); }} className="justify-between"><StatusControl icon={item === "Agent" ? RiInfinityLine : item === "Ask" ? RiFocus3Line : RiListCheck3}><span className="text-body-medium text-text-primary">{item}</span></StatusControl>{item === mode ? <RiCheckLine className="size-4" aria-hidden /> : null}</DropdownItem>)}</DropdownGroup>
          </DropdownPopover>
        </Dropdown>
        <div className="flex items-center gap-1 rounded-[40px] bg-background-tertiary-default py-1 pr-2 pl-1.5" aria-label={`${usage}% context used`}>
          <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" className="shrink-0 -rotate-90"><circle cx="8" cy="8" r="6" fill="none" stroke="var(--color-neutral-300)" strokeWidth="2.5" /><circle cx="8" cy="8" r="6" fill="none" stroke="var(--color-neutral-500)" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={`${(usage / 100) * circumference} ${circumference}`} /></svg>
          <span className="text-body-2-medium whitespace-nowrap text-text-secondary">{Math.round(usage)}%</span>
        </div>
      </div>
    </div>
  );
}

export function AiChatComposerPreview({ composerProps, statusBarProps, onSubmit, working, className, ref, ...props }: AiChatComposerPreviewProps) {
  return (
    <div ref={ref} className={cx("flex w-full max-w-[560px] flex-col gap-2.5", className)} {...props}>
      <Composer {...composerProps} working={working ?? composerProps?.working} onSubmit={onSubmit ?? composerProps?.onSubmit} />
      <StatusBar {...statusBarProps} />
    </div>
  );
}
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type FormEvent,
  type HTMLAttributes,
  type KeyboardEvent,
  type ReactNode,
  type Ref,
} from "react";
import {
  RiAddLine,
  RiArrowDownSLine,
  RiArrowUpLine,
  RiAttachment2,
  RiCheckLine,
  RiCodeSSlashLine,
  RiFileExcel2Line,
  RiFileTextLine,
  RiFocus3Line,
  RiFolderLine,
  RiGitBranchLine,
  RiInfinityLine,
  RiListCheck3,
  RiMicLine,
  RiSlideshowLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import {
  Dropdown,
  DropdownDivider,
  DropdownGroup,
  DropdownItem,
  DropdownPopover,
  DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { ComposerLoader } from "@/components/blocks/composer-loader/composer-loader";
import { cx } from "@/utils/cx";

/** Iridescent rim gradient for the working-state loader (UI chrome, not sample data). */
const COMPOSER_LOADER_COLORS: [string, string, string, string] = [
  "#5eead4",
  "#46baec",
  "#e633a4",
  "#00faa7",
];

export type ComposerModel = "Composer 2.5" | "GPT-5.6 Sol" | "Fable 5" | "Sonnet 5";
export type ComposerAgentMode = "Agent" | "Ask" | "Plan";
export type ComposerAttachmentAction = "files" | "goal" | "plan" | "documents" | "spreadsheets" | "presentations" | "code";
export type ComposerMenu = "attachments" | "model" | null;
export type StatusBarMenu = "project" | "mode" | null;

export interface ComposerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSubmit" | "onChange"> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  onSubmit?: (value: string, files: File[]) => void | Promise<void>;
  placeholder?: string;
  model?: ComposerModel;
  defaultModel?: ComposerModel;
  onModelChange?: (model: ComposerModel) => void;
  effort?: number;
  defaultEffort?: number;
  onEffortChange?: (effort: number) => void;
  files?: File[];
  onFilesChange?: (files: File[]) => void;
  onAttachmentAction?: (action: ComposerAttachmentAction) => void;
  openMenu?: ComposerMenu;
  defaultOpenMenu?: ComposerMenu;
  onOpenMenuChange?: (menu: ComposerMenu) => void;
  working?: boolean;
  disabled?: boolean;
  voiceActive?: boolean;
  onVoiceActiveChange?: (active: boolean) => void;
  ref?: Ref<HTMLDivElement>;
}

export interface StatusBarProps extends HTMLAttributes<HTMLDivElement> {
  branch?: string;
  onBranchAction?: (branch: string) => void;
  project?: string;
  projects?: string[];
  onProjectChange?: (project: string) => void;
  mode?: ComposerAgentMode;
  onModeChange?: (mode: ComposerAgentMode) => void;
  contextUsage?: number;
  openMenu?: StatusBarMenu;
  defaultOpenMenu?: StatusBarMenu;
  onOpenMenuChange?: (menu: StatusBarMenu) => void;
  ref?: Ref<HTMLDivElement>;
}

export interface AiChatComposerPreviewProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSubmit"> {
  composerProps?: ComposerProps;
  statusBarProps?: StatusBarProps;
  onSubmit?: ComposerProps["onSubmit"];
  working?: boolean;
  ref?: Ref<HTMLDivElement>;
}

export interface GlassComposerProps extends HTMLAttributes<HTMLDivElement> {
  glass?: boolean;
  children: ReactNode;
  ref?: Ref<HTMLDivElement>;
}

const MODELS: ComposerModel[] = ["Composer 2.5", "GPT-5.6 Sol", "Fable 5", "Sonnet 5"];
const EFFORT_LABELS = ["Minimal", "Medium", "High", "Max", "Extended", "Ultra"];

/** Ultra effort jet: a purple flame trailing left off the thumb. Same raw-WebGL
 *  recipe as PlanArtFlame — fullscreen quad, rAF loop, CSS fallback. */
const EFFORT_FLAME_VERT = /* glsl */ `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
  v_uv = a_pos * 0.5 + 0.5;
  gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;

const EFFORT_FLAME_FRAG = /* glsl */ `
precision mediump float;
uniform float u_time;
uniform vec2 u_res;
varying vec2 v_uv;

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) {
    v += a * noise(p);
    p = p * 2.03 + vec2(17.0, 9.2);
    a *= 0.5;
  }
  return v;
}

float lobe(vec2 uv, float yOff, float tOff, float time) {
  // Scroll noise to the left so the jet reads as exhaust off the thumb.
  float n = fbm(vec2(uv.x * 5.4 + time * 2.35 + tOff, uv.y * 4.2 + time * 0.45));
  float n2 = fbm(vec2(uv.x * 9.0 - time * 3.1 + tOff, uv.y * 7.0));
  float tail = 1.0 - uv.x;
  float mid = 0.5 + yOff + (n - 0.5) * mix(0.06, 0.32, tail);
  float w = mix(0.52, 0.045, pow(tail, 0.62));
  w *= 0.72 + 0.4 * n2;
  float d = abs(uv.y - mid) / max(w, 0.008);
  d += (n2 - 0.5) * 0.45;
  float body = 1.0 - smoothstep(0.05, 1.05, d);
  body *= pow(uv.x, 0.28);
  body *= smoothstep(0.0, 0.1, uv.x);
  return body;
}

void main() {
  vec2 uv = v_uv;
  float time = u_time;

  float a = lobe(uv, 0.0, 0.0, time);
  float b = lobe(uv, 0.07, 4.7, time * 1.08);
  float c = lobe(uv, -0.06, 9.1, time * 0.92);
  float heat = max(a, max(b * 0.88, c * 0.82));
  heat += a * b * 0.18;
  heat *= 0.86 + 0.14 * sin(time * 21.0 + heat * 9.0);

  vec3 deep = vec3(0.26, 0.05, 0.48);
  vec3 midc = vec3(0.64, 0.24, 0.96);
  vec3 hot = vec3(0.9, 0.72, 1.0);
  vec3 core = vec3(1.0, 0.95, 1.0);

  float coreMask = heat * heat * smoothstep(0.42, 0.95, uv.x) * (1.0 - abs(uv.y - 0.5) * 1.6);
  vec3 col = mix(deep, midc, clamp(heat * 1.35, 0.0, 1.0));
  col = mix(col, hot, clamp(heat * heat * 1.7, 0.0, 1.0));
  col = mix(col, core, clamp(coreMask, 0.0, 1.0));

  float alpha = clamp(heat * 1.4, 0.0, 1.0);

  // White-lilac sparks in the wake, elongated and drifting left.
  for (int i = 0; i < 4; i++) {
    float fi = float(i);
    float sx = 7.5 + fi * 4.5;
    float sy = 4.4 + fi * 1.6;
    vec2 sp = vec2(uv.x * sx + time * (1.55 + fi * 0.45), uv.y * sy);
    vec2 cell = floor(sp);
    float sh = hash(cell + fi * 19.1);
    if (sh > 0.58) {
      vec2 pos = vec2(0.3 + 0.45 * hash(cell + 2.2), 0.22 + 0.56 * hash(cell + 7.7));
      vec2 delta = fract(sp) - pos;
      float spark = smoothstep(0.2, 0.03, length(delta * vec2(0.38, 3.1)));
      float life = fract(time * (0.38 + sh * 0.4) + sh * 5.0);
      float glow = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.5, 0.95, life));
      float gate = smoothstep(0.08, 0.45, heat + 0.15) * smoothstep(0.05, 0.35, uv.x);
      float lum = spark * glow * gate * (0.75 + 0.25 * sh);
      col += vec3(1.0, 0.9, 1.0) * lum;
      alpha = max(alpha, lum);
    }
  }

  gl_FragColor = vec4(col, alpha);
}
`;

function EffortFlame() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setFailed(true);
      return;
    }

    const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false });
    if (!gl) {
      setFailed(true);
      return;
    }

    const compile = (type: number, source: string) => {
      const shader = gl.createShader(type)!;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;
    };
    const vert = compile(gl.VERTEX_SHADER, EFFORT_FLAME_VERT);
    const frag = compile(gl.FRAGMENT_SHADER, EFFORT_FLAME_FRAG);
    if (!vert || !frag) {
      setFailed(true);
      return;
    }
    const program = gl.createProgram()!;
    gl.attachShader(program, vert);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      setFailed(true);
      return;
    }
    gl.useProgram(program);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const aPos = gl.getAttribLocation(program, "a_pos");
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    const uTime = gl.getUniformLocation(program, "u_time");
    const uRes = gl.getUniformLocation(program, "u_res");

    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    const host = canvas.parentElement;
    const fit = () => {
      const box = (host ?? canvas).getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = Math.max(1, Math.floor(box.width * dpr));
      canvas.height = Math.max(1, Math.floor(box.height * dpr));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uRes, canvas.width, canvas.height);
    };
    fit();
    const observer = new ResizeObserver(fit);
    observer.observe(host ?? canvas);

    let raf = 0;
    const start = performance.now();
    const draw = (now: number) => {
      gl.uniform1f(uTime, (now - start) / 1000);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, []);

  if (failed) {
    return <span aria-hidden className="bui-effort-ultra-flame-static" />;
  }
  return <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />;
}

function useControllable<T>(controlled: T | undefined, initial: T, onChange?: (value: T) => void) {
  const [internal, setInternal] = useState(initial);
  const value = controlled ?? internal;
  const setValue = (next: T) => {
    if (controlled === undefined) setInternal(next);
    onChange?.(next);
  };
  return [value, setValue] as const;
}

function MenuText({ title, detail }: { title: string; detail?: string }) {
  return (
    <span className="min-w-0 truncate text-body-medium whitespace-nowrap">
      <span className="text-text-primary">{title}</span>
      {detail ? <span className="ml-1.5 text-text-secondary">{detail}</span> : null}
    </span>
  );
}

function PluginIcon({ tone, children }: { tone: string; children: ReactNode }) {
  return (
    <span className={cx("flex size-6 shrink-0 items-center justify-center rounded-md text-white shadow-xs", tone)}>
      {children}
    </span>
  );
}

function SelectionDot({ selected }: { selected: boolean }) {
  return (
    <span
      aria-hidden
      className={cx(
        "relative flex size-3.5 shrink-0 items-center justify-center rounded-full border shadow-xs transition-colors duration-200",
        selected
          ? "border-accent-500 bg-gradient-to-b from-accent-500 to-accent-600"
          : "border-border-checkbox-default bg-background-primary-default",
      )}
    >
      <span className={cx("size-[5px] rounded-full bg-control-indicator-background transition-[scale,opacity] duration-200", selected ? "scale-100 opacity-100" : "scale-0 opacity-0")} />
    </span>
  );
}

function AttachmentMenu({ onAction, onFiles, onClose }: { onAction?: (action: ComposerAttachmentAction) => void; onFiles: () => void; onClose: () => void }) {
  const select = (action: ComposerAttachmentAction) => {
    if (action === "files") onFiles();
    onAction?.(action);
    onClose();
  };
  return (
    <DropdownPopover aria-label="Add to chat" placement="top start" offset={8} className="w-[361px] max-w-[calc(100vw-32px)] p-2" dialogClassName="gap-2">
      <DropdownGroup label="Add">
        <DropdownItem className="py-1.5" onSelect={() => select("files")}><RiAttachment2 className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Files and folders" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("goal")}><RiFocus3Line className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Goal" detail="Set a goal for faster results" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("plan")}><RiListCheck3 className="size-5 shrink-0 text-foreground-icon-secondary" /><MenuText title="Plan mode" detail="Manage complex tasks" /></DropdownItem>
      </DropdownGroup>
      <DropdownGroup label="Plugins">
        <DropdownItem className="py-1.5" onSelect={() => select("documents")}><PluginIcon tone="bg-blue-500"><RiFileTextLine className="size-4" /></PluginIcon><MenuText title="Documents" detail="Create and edit documents" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("spreadsheets")}><PluginIcon tone="bg-emerald-500"><RiFileExcel2Line className="size-4" /></PluginIcon><MenuText title="Spreadsheets" detail="Generate spreadsheets" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("presentations")}><PluginIcon tone="bg-orange-500"><RiSlideshowLine className="size-4" /></PluginIcon><MenuText title="Presentations" detail="Create marketing assets" /></DropdownItem>
        <DropdownItem className="py-1.5" onSelect={() => select("code")}><PluginIcon tone="bg-violet-500"><RiCodeSSlashLine className="size-4" /></PluginIcon><MenuText title="Code blocks" detail="Write and edit existing code" /></DropdownItem>
      </DropdownGroup>
    </DropdownPopover>
  );
}

function ModelMenu({ model, setModel, effort, setEffort }: { model: ComposerModel; setModel: (model: ComposerModel) => void; effort: number; setEffort: (effort: number) => void }) {
  const clampedEffort = Math.min(5, Math.max(0, effort));
  const isUltra = clampedEffort === 5;
  return (
    <DropdownPopover aria-label="Model settings" placement="top end" offset={8} className="w-[266px] max-w-[calc(100vw-32px)]">
      <DropdownGroup label="Models">
        {MODELS.map((item) => (
          <DropdownItem key={item} selected={item === model} onSelect={() => setModel(item)} className="justify-between p-2" >
            <span className="truncate text-body-medium whitespace-nowrap text-text-primary">{item}</span>
            <SelectionDot selected={item === model} />
          </DropdownItem>
        ))}
      </DropdownGroup>
      <DropdownDivider className="mt-[7px] mb-3" />
      <div className="flex w-full flex-col">
        <span className="pl-2 text-body-medium text-text-secondary">Effort <span className="text-text-primary">{EFFORT_LABELS[clampedEffort]}</span></span>
        <div className="flex items-center justify-between px-2 pt-2 pb-[3px] text-body-2-medium text-text-secondary"><span>Faster</span><span>Smarter</span></div>
        <div className="px-2 pb-2">
          <div className={cx("relative", isUltra && "bui-effort-ultra")}>
            <div className="relative h-[27px] overflow-hidden rounded-lg bg-background-secondary-default">
              <span
                className={cx(
                  "absolute overflow-hidden rounded-lg bg-background-tertiary-hover transition-[width,background-color] duration-150 ease-out",
                  isUltra ? "inset-0 bui-effort-ultra-rail" : "inset-y-0 left-0",
                )}
                style={isUltra ? undefined : { width: `calc(${clampedEffort * 20}% + 16.8px)` }}
              >
                {isUltra ? <EffortFlame /> : null}
              </span>
              <span className={cx("pointer-events-none absolute inset-x-[9px] top-[7px] flex h-[13px] items-center justify-between transition-opacity duration-150", isUltra && "opacity-0")}>
                {EFFORT_LABELS.map((label, index) => <span key={label} className={cx("h-full w-[3px] rounded-[2px] bg-foreground-icon-tertiary", index <= clampedEffort ? "opacity-100" : "opacity-30")} />)}
              </span>
              <input aria-label="Effort" type="range" min={0} max={5} step={1} value={clampedEffort} onChange={(event) => setEffort(Number(event.currentTarget.value))} className="absolute inset-0 size-full cursor-grab opacity-0 active:cursor-grabbing" />
              <span aria-hidden className="pointer-events-none absolute top-0 z-10 h-[27px] w-[21px] -translate-x-1/2 rounded-[7px] border border-border-checkbox-default bg-background-primary-default shadow-xs transition-[left] duration-150 ease-out" style={{ left: `calc(10.5px + (100% - 21px) * ${clampedEffort / 5})` }} />
            </div>
          </div>
        </div>
      </div>
    </DropdownPopover>
  );
}

export function GlassComposer({ glass = false, className, children, ref, ...props }: GlassComposerProps) {
  return (
    <div ref={ref} className={cx("relative", className)} data-glass={glass || undefined} {...props}>
      <span aria-hidden className={cx("pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-[480ms]", glass && "opacity-100")}>
        <span className="absolute inset-y-2 left-2 size-9 rounded-full border border-white/40 bg-white/20 shadow-[inset_0_1px_0_rgba(255,255,255,.55),0_6px_24px_rgba(34,114,255,.14)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
        <span className="absolute top-2.5 right-[104px] h-8 w-[84px] rounded-xl border border-white/35 bg-white/15 shadow-[inset_0_1px_0_rgba(255,255,255,.45)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
        <span className="absolute inset-y-2 right-[52px] size-9 rounded-full border border-white/40 bg-white/20 shadow-[inset_0_1px_0_rgba(255,255,255,.55)] backdrop-blur-md dark:border-white/15 dark:bg-white/10" />
      </span>
      <div className={cx("relative transition-colors duration-[480ms]", glass && "[&_button:not([aria-label='Send_message'])]:!border-transparent [&_button:not([aria-label='Send_message'])]:!bg-transparent [&_button:not([aria-label='Send_message'])]:!shadow-none")}>{children}</div>
    </div>
  );
}

export function Composer({
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  onSubmit,
  placeholder = "Ask me anything",
  model: controlledModel,
  defaultModel = "Fable 5",
  onModelChange,
  effort: controlledEffort,
  defaultEffort = 1,
  onEffortChange,
  files: controlledFiles,
  onFilesChange,
  onAttachmentAction,
  openMenu: controlledOpenMenu,
  defaultOpenMenu = null,
  onOpenMenuChange,
  working: controlledWorking,
  disabled = false,
  voiceActive = false,
  onVoiceActiveChange,
  className,
  ref,
  ...props
}: ComposerProps) {
  const [value, setValue] = useControllable(controlledValue, defaultValue, onValueChange);
  const [model, setModel] = useControllable(controlledModel, defaultModel, onModelChange);
  const [effort, setEffort] = useControllable(controlledEffort, defaultEffort, onEffortChange);
  const [files, setFiles] = useControllable(controlledFiles, [], onFilesChange);
  const [openMenu, setOpenMenu] = useControllable(controlledOpenMenu, defaultOpenMenu, onOpenMenuChange);
  const [internalVoiceActive, setInternalVoiceActive] = useState(voiceActive);
  const [internalWorking, setInternalWorking] = useState(false);
  const fileInput = useRef<HTMLInputElement>(null);
  const working = controlledWorking ?? internalWorking;
  const currentVoiceActive = onVoiceActiveChange ? voiceActive : internalVoiceActive;
  const blocked = disabled || working;

  const submit = async () => {
    const message = value.trim();
    if (blocked || (!message && files.length === 0)) return;
    const result = onSubmit?.(message, files);
    if (result instanceof Promise && controlledWorking === undefined) {
      setInternalWorking(true);
      try { await result; } finally { setInternalWorking(false); }
    }
    if (controlledValue === undefined) setValue("");
    if (controlledFiles === undefined) setFiles([]);
  };

  const onFormSubmit = (event: FormEvent) => { event.preventDefault(); void submit(); };
  const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      void submit();
    }
  };
  const onFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selected = Array.from(event.currentTarget.files ?? []);
    event.currentTarget.value = "";
    if (selected.length) setFiles([...files, ...selected]);
  };

  return (
    <div ref={ref} className={cx("relative", className)} {...props}>
      <span aria-hidden className="absolute inset-0 rounded-full bg-black opacity-10 blur-[50px] dark:hidden" />
      <ComposerLoader active={working} surface={false} colors={COMPOSER_LOADER_COLORS}>
        <GlassComposer glass={working}>
          <form onSubmit={onFormSubmit} className="relative flex min-h-[52px] w-full items-center gap-2.5 rounded-full bg-background-primary-default py-2 pr-2 pl-2 shadow-xs">
            <input ref={fileInput} type="file" multiple className="sr-only" onChange={onFileChange} />
            <Dropdown isOpen={openMenu === "attachments"} onOpenChange={(open) => setOpenMenu(open ? "attachments" : null)}>
              <DropdownTrigger aria-label="Add attachment" isDisabled={blocked} className="flex size-9 shrink-0 items-center justify-center rounded-full bg-ai-chat-composer-add-background p-2 transition-colors duration-150 ease hover:bg-ai-chat-composer-add-hover-background">
                <RiAddLine className={cx("size-5 text-foreground-icon-primary transition-transform duration-200 ease", openMenu === "attachments" && "rotate-45")} aria-hidden />
              </DropdownTrigger>
              <AttachmentMenu onAction={onAttachmentAction} onFiles={() => fileInput.current?.click()} onClose={() => setOpenMenu(null)} />
            </Dropdown>

            <input aria-label="Message" placeholder={placeholder} value={value} disabled={blocked} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={onInputKeyDown} className="h-5 min-w-0 flex-1 bg-transparent text-body-regular text-text-primary caret-accent-500 outline-none placeholder:text-text-tertiary disabled:cursor-not-allowed" />

            <Dropdown isOpen={openMenu === "model"} onOpenChange={(open) => setOpenMenu(open ? "model" : null)}>
              <DropdownTrigger isDisabled={blocked} className="flex h-8 max-w-[8.5rem] shrink-0 items-center gap-1 rounded-xl bg-background-primary-default py-1.5 pr-1 pl-2 transition-colors duration-150 ease hover:bg-background-primary-hover">
                <span className="min-w-0 truncate px-0.5 text-body-medium text-text-secondary">{model}</span>
                <RiArrowDownSLine className={cx("size-[18px] shrink-0 text-foreground-icon-secondary transition-transform duration-200 ease", openMenu === "model" && "rotate-180")} aria-hidden />
              </DropdownTrigger>
              <ModelMenu model={model} setModel={setModel} effort={effort} setEffort={setEffort} />
            </Dropdown>

            <div className="flex shrink-0 items-center gap-2 pl-1.5">
              <Button type="button" variant="secondary" iconOnly leadingIcon={RiMicLine} aria-label="Voice input" aria-pressed={currentVoiceActive} disabled={blocked} onClick={() => { const next = !currentVoiceActive; setInternalVoiceActive(next); onVoiceActiveChange?.(next); }} className={cx("rounded-full", currentVoiceActive && "border-accent-500 bg-accent-50 text-accent-600 dark:bg-accent-950")} />
              <Button type="submit" iconOnly leadingIcon={RiArrowUpLine} aria-label="Send message" disabled={blocked} className="rounded-full disabled:opacity-50" />
            </div>
          </form>
        </GlassComposer>
      </ComposerLoader>
      {files.length ? (
        <div className="absolute bottom-[58px] left-2 flex max-w-[calc(100%-16px)] gap-1 overflow-x-auto" aria-label="Attachments">
          {files.map((file, index) => (
            <button key={`${file.name}-${file.lastModified}-${index}`} type="button" onClick={() => setFiles(files.filter((_, fileIndex) => fileIndex !== index))} className="flex h-7 shrink-0 items-center gap-1 rounded-lg border border-border-button-default bg-background-primary-default px-2 text-body-2-medium text-text-secondary shadow-xs" aria-label={`Remove ${file.name}`}>
              <RiAttachment2 className="size-3.5" aria-hidden /><span className="max-w-32 truncate">{file.name}</span>
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

function StatusControl({ icon: Icon, children }: { icon: typeof RiFolderLine; children: ReactNode }) {
  return <span className="flex min-w-0 items-center gap-1"><Icon className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden />{children}</span>;
}

export function StatusBar({
  branch = "Main",
  onBranchAction,
  project: controlledProject,
  projects = ["users/mertcan/project-sea", "users/desktop/vibl", "users/documents/boardcn"],
  onProjectChange,
  mode: controlledMode,
  onModeChange,
  contextUsage = 57,
  openMenu: controlledOpenMenu,
  defaultOpenMenu = null,
  onOpenMenuChange,
  className,
  ref,
  ...props
}: StatusBarProps) {
  const defaultProject = controlledProject ?? projects[0]?.split("/").at(-1) ?? "project-sea";
  const [project, setProject] = useControllable(controlledProject, defaultProject, onProjectChange);
  const [mode, setMode] = useControllable(controlledMode, "Agent", onModeChange);
  const [openMenu, setOpenMenu] = useControllable(controlledOpenMenu, defaultOpenMenu, onOpenMenuChange);
  const usage = Math.min(100, Math.max(0, contextUsage));
  const circumference = 2 * Math.PI * 6;

  return (
    <div ref={ref} className={cx("flex h-[26px] w-full items-center justify-between gap-3", className)} {...props}>
      <div className="flex min-w-0 items-center gap-3">
        <button type="button" onClick={() => onBranchAction?.(branch)} className="flex shrink-0 cursor-pointer items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiGitBranchLine className="size-4 shrink-0 -scale-y-100 text-foreground-icon-secondary" aria-hidden /><span className="text-body-2-medium whitespace-nowrap text-text-secondary">{branch}</span></button>
        <Dropdown isOpen={openMenu === "project"} onOpenChange={(open) => setOpenMenu(open ? "project" : null)}>
          <DropdownTrigger className="flex min-w-0 items-center gap-1 rounded-sm"><RiFolderLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex min-w-0 items-center"><span className="max-w-32 truncate text-body-2-medium whitespace-nowrap text-text-secondary">{project}</span><RiArrowDownSLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /></span></DropdownTrigger>
          <DropdownPopover aria-label="Local folders" placement="top start" className="w-[290px]">
            <DropdownGroup label="Local Folders">{projects.map((path) => { const name = path.split("/").at(-1) ?? path; return <DropdownItem key={path} selected={name === project} onSelect={() => { setProject(name); setOpenMenu(null); }} className="justify-between"><span className="truncate text-body-medium text-text-primary">{path}</span>{name === project ? <RiCheckLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /> : null}</DropdownItem>; })}</DropdownGroup>
          </DropdownPopover>
        </Dropdown>
      </div>
      <div className="flex shrink-0 items-center gap-3">
        <Dropdown isOpen={openMenu === "mode"} onOpenChange={(open) => setOpenMenu(open ? "mode" : null)}>
          <DropdownTrigger className="flex items-center gap-1 rounded-sm"><RiInfinityLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center"><span className="text-body-2-medium whitespace-nowrap text-text-secondary">{mode}</span><RiArrowDownSLine className="size-4 shrink-0 text-foreground-icon-secondary" aria-hidden /></span></DropdownTrigger>
          <DropdownPopover aria-label="Agent mode" placement="top end" className="w-[210px]">
            <DropdownGroup label="Mode">{(["Agent", "Ask", "Plan"] as ComposerAgentMode[]).map((item) => <DropdownItem key={item} selected={item === mode} onSelect={() => { setMode(item); setOpenMenu(null); }} className="justify-between"><StatusControl icon={item === "Agent" ? RiInfinityLine : item === "Ask" ? RiFocus3Line : RiListCheck3}><span className="text-body-medium text-text-primary">{item}</span></StatusControl>{item === mode ? <RiCheckLine className="size-4" aria-hidden /> : null}</DropdownItem>)}</DropdownGroup>
          </DropdownPopover>
        </Dropdown>
        <div className="flex items-center gap-1 rounded-[40px] bg-background-tertiary-default py-1 pr-2 pl-1.5" aria-label={`${usage}% context used`}>
          <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" className="shrink-0 -rotate-90"><circle cx="8" cy="8" r="6" fill="none" stroke="var(--color-neutral-300)" strokeWidth="2.5" /><circle cx="8" cy="8" r="6" fill="none" stroke="var(--color-neutral-500)" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={`${(usage / 100) * circumference} ${circumference}`} /></svg>
          <span className="text-body-2-medium whitespace-nowrap text-text-secondary">{Math.round(usage)}%</span>
        </div>
      </div>
    </div>
  );
}

export function AiChatComposerPreview({ composerProps, statusBarProps, onSubmit, working, className, ref, ...props }: AiChatComposerPreviewProps) {
  return (
    <div ref={ref} className={cx("flex w-full max-w-[560px] flex-col gap-2.5", className)} {...props}>
      <Composer {...composerProps} working={working ?? composerProps?.working} onSubmit={onSubmit ?? composerProps?.onSubmit} />
      <StatusBar {...statusBarProps} />
    </div>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

AiChatComposerPreview

PropTypeDefaultDescription
composerPropsComposerProps
onSubmit(value: string, files: File[]) => void | Promise<void>
statusBarPropsStatusBarProps
workingboolean

Composer

PropTypeDefaultDescription
defaultEffortnumber1
defaultModel"Composer 2.5" | "GPT-5.6 Sol" | "Fable 5" | "Sonnet 5"Fable 5
defaultOpenMenu"attachments" | "model"null
defaultValuestring
disabledbooleanfalse
effortnumber
filesFile[]
model"Composer 2.5" | "GPT-5.6 Sol" | "Fable 5" | "Sonnet 5"
onAttachmentAction(action: ComposerAttachmentAction) => void
onEffortChange(effort: number) => void
onFilesChange(files: File[]) => void
onModelChange(model: ComposerModel) => void
onOpenMenuChange(menu: ComposerMenu) => void
onSubmit(value: string, files: File[]) => void | Promise<void>
onValueChange(value: string) => void
onVoiceActiveChange(active: boolean) => void
openMenu"attachments" | "model"
placeholderstringAsk me anything
valuestring
voiceActivebooleanfalse
workingboolean

GlassComposer

PropTypeDefaultDescription
glassbooleanfalse

StatusBar

PropTypeDefaultDescription
branchstringMain
contextUsagenumber57
defaultOpenMenu"project" | "mode"null
mode"Agent" | "Ask" | "Plan"
onBranchAction(branch: string) => void
onModeChange(mode: ComposerAgentMode) => void
onOpenMenuChange(menu: StatusBarMenu) => void
onProjectChange(project: string) => void
openMenu"project" | "mode"
projectstring
projectsstring[]["users/mertcan/project-sea", "users/desktop/vibl", "users/documents/boardcn"]