Unverified Commit d3b4b997 authored by Daniel Hiltgen's avatar Daniel Hiltgen Committed by GitHub
Browse files

app: add code for macOS and Windows apps under 'app' (#12933)



* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------
Co-authored-by: default avatarjmorganca <jmorganca@gmail.com>
parent a4770107
import { forwardRef } from "react";
interface ButtonProps {
isVisible?: boolean;
isActive: boolean;
onToggle: () => void;
}
export const WebSearchButton = forwardRef<HTMLButtonElement, ButtonProps>(
function WebSearchButton({ isVisible, isActive, onToggle }, ref) {
if (!isVisible) return null;
return (
<button
ref={ref}
title={isActive ? "Disable web search" : "Enable web search"}
onClick={onToggle}
className={`select-none flex items-center justify-center rounded-full h-9 w-9 bg-white dark:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer transition-all whitespace-nowrap border border-transparent ${
isActive
? "text-[rgba(0,115,255,1)] dark:text-[rgba(70,155,255,1)]"
: "text-neutral-500 dark:text-neutral-400"
}`}
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
</button>
);
},
);
import { Link } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
export function SidebarLayout({
sidebar,
children,
}: React.PropsWithChildren<{
sidebar: React.ReactNode;
collapsible?: boolean;
chatId?: string;
}>) {
const { settings, setSettings } = useSettings();
const isWindows = navigator.platform.toLowerCase().includes("win");
return (
<div className={`flex transition-[width] duration-300 dark:bg-neutral-900`}>
<div
className={`absolute flex mx-2 py-2 z-20 items-center transition-[left] duration-375 text-neutral-500 dark:text-neutral-400 ${settings.sidebarOpen ? (isWindows ? "left-2" : "left-[204px]") : isWindows ? "left-2" : "left-20"}`}
>
<button
onClick={() => setSettings({ SidebarOpen: !settings.sidebarOpen })}
onMouseDown={(e) => {
e.stopPropagation();
}}
className="h-9 w-9 flex items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700/75 cursor-pointer"
aria-label={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
title={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
>
<svg
className="h-5 w-5 fill-current"
viewBox="0 0 24 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.76132 16.6344H9.58103V1.59842H7.76132V16.6344ZM4.20898 18.2316H19.124C21.6518 18.2316 23.1293 16.6963 23.1293 14.0209V4.2205C23.1293 1.54512 21.6518 0.00351715 19.124 0.00351715H4.20898C1.54336 0.00351715 0 1.54512 0 4.2205V14.0209C0 16.6963 1.54336 18.2316 4.20898 18.2316ZM4.31191 16.3184C2.79628 16.3184 1.91327 15.4434 1.91327 13.926V4.31542C1.91327 2.79979 2.79628 1.91678 4.31191 1.91678H18.8174C20.333 1.91678 21.216 2.79979 21.216 4.31542V13.926C21.216 15.4434 20.333 16.3184 18.8174 16.3184H4.31191ZM5.85116 5.50038C6.1951 5.50038 6.49217 5.20507 6.49217 4.87968C6.49217 4.54628 6.1951 4.25722 5.85116 4.25722H3.8412C3.49725 4.25722 3.20819 4.54628 3.20819 4.87968C3.20819 5.20507 3.49725 5.50038 3.8412 5.50038H5.85116ZM5.85116 8.1158C6.1951 8.1158 6.49217 7.82049 6.49217 7.4871C6.49217 7.1537 6.1951 6.8744 5.85116 6.8744H3.8412C3.49725 6.8744 3.20819 7.1537 3.20819 7.4871C3.20819 7.82049 3.49725 8.1158 3.8412 8.1158H5.85116ZM5.85116 10.725C6.1951 10.725 6.49217 10.4439 6.49217 10.1105C6.49217 9.77713 6.1951 9.48983 5.85116 9.48983H3.8412C3.49725 9.48983 3.20819 9.77713 3.20819 10.1105C3.20819 10.4439 3.49725 10.725 3.8412 10.725H5.85116Z" />
</svg>
</button>
<Link
to="/c/$chatId"
params={{ chatId: "new" }}
title="New chat"
className={`flex ml-1 items-center justify-center rounded-full transition-opacity duration-375 h-9 w-9 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
settings.sidebarOpen
? "opacity-0 pointer-events-none"
: "opacity-100"
}`}
>
<svg
className="h-5 w-5 fill-current"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
</svg>
</Link>
</div>
<div
className={`flex flex-col transition-[width] duration-300 max-h-screen ${settings.sidebarOpen ? "w-64" : "w-0"}`}
>
<div
onDoubleClick={() => window.doubleClick && window.doubleClick()}
onMouseDown={() => window.drag && window.drag()}
className="flex-none h-13 w-full"
></div>
{settings.sidebarOpen && sidebar}
</div>
<main
className={`flex flex-1 flex-col min-w-0 transition-all duration-300`}
>
<div
className={`h-13 flex-none w-full z-10 flex items-center bg-white dark:bg-neutral-900 ${isWindows ? "xl:hidden" : "xl:fixed xl:bg-transparent xl:dark:bg-transparent"}`}
onDoubleClick={() => window.doubleClick && window.doubleClick()}
onMouseDown={() => window.drag && window.drag()}
></div>
{children}
</main>
</div>
);
}
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import React, { forwardRef } from "react";
import { TouchTarget } from "./button";
import { Link } from "./link";
const colors = {
red: "bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20",
orange:
"bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20",
amber:
"bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15",
yellow:
"bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15",
lime: "bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15",
green:
"bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20",
emerald:
"bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20",
teal: "bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20",
cyan: "bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15",
sky: "bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20",
blue: "bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25",
indigo:
"bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20",
violet:
"bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20",
purple:
"bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20",
fuchsia:
"bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20",
pink: "bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20",
rose: "bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20",
zinc: "bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10",
};
type BadgeProps = { color?: keyof typeof colors };
export function Badge({
color = "zinc",
className,
...props
}: BadgeProps & React.ComponentPropsWithoutRef<"span">) {
return (
<span
{...props}
className={clsx(
className,
"inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline",
colors[color],
)}
/>
);
}
export const BadgeButton = forwardRef(function BadgeButton(
{
color = "zinc",
className,
children,
...props
}: BadgeProps & { className?: string; children: React.ReactNode } & (
| Omit<Headless.ButtonProps, "as" | "className">
| Omit<React.ComponentPropsWithoutRef<typeof Link>, "className">
),
ref: React.ForwardedRef<HTMLElement>,
) {
let classes = clsx(
className,
"group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
);
return "href" in props ? (
<Link
{...(props as Omit<
React.ComponentPropsWithoutRef<typeof Link>,
"className"
>)}
className={classes}
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Link>
) : (
<Headless.Button
{...(props as Omit<Headless.ButtonProps, "as" | "className">)}
className={classes}
ref={ref as React.ForwardedRef<HTMLButtonElement>}
>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Headless.Button>
);
});
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import React, { forwardRef } from "react";
import { Link } from "./link";
const styles = {
base: [
// Base
"relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-medium",
// Sizing
"px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6",
// Focus
"focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
// Disabled
"data-disabled:opacity-50",
// Icon
"*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]",
],
solid: [
// Optical border, implemented as the button background to avoid corner artifacts
"border-transparent bg-(--btn-border)",
// Dark mode: border is rendered on `after` so background is set to button background
"dark:bg-(--btn-bg)",
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
"before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)",
// Drop shadow, applied to the inset `before` layer so it blends with the border
"before:shadow-sm",
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
"dark:before:hidden",
// Dark mode: Subtle white outline is applied using a border
"dark:border-white/5",
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
"after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]",
// Inner highlight shadow
"after:shadow-[inset_0_1px_--theme(--color-white/15%)]",
// White overlay on hover
"data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)",
// Dark mode: `after` layer expands to cover entire button
"dark:after:-inset-px dark:after:rounded-lg",
// Disabled
"data-disabled:before:shadow-none data-disabled:after:shadow-none",
],
outline: [
// Base
"border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5",
// Dark mode
"dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5",
// Icon
"[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]",
],
plain: [
// Base
"border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5",
// Dark mode
"dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10",
// Icon
"[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]",
],
colors: {
"dark/zinc": [
"text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10",
"dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5",
"[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]",
],
light: [
"text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15",
"dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]",
"[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]",
],
"dark/white": [
"text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10",
"dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5",
"[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]",
],
dark: [
"text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10",
"dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]",
"[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]",
],
white: [
"text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15",
"dark:[--btn-hover-overlay:var(--color-zinc-950)]/5",
"[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]",
],
zinc: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90",
"dark:[--btn-hover-overlay:var(--color-white)]/5",
"[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]",
],
indigo: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90",
"[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]",
],
cyan: [
"text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25",
"[--btn-icon:var(--color-cyan-500)]",
],
red: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90",
"[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]",
],
orange: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90",
"[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]",
],
amber: [
"text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80",
"[--btn-icon:var(--color-amber-600)]",
],
yellow: [
"text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80",
"[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]",
],
lime: [
"text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80",
"[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]",
],
green: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90",
"[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80",
],
emerald: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90",
"[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80",
],
teal: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90",
"[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80",
],
sky: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80",
"[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80",
],
blue: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90",
"[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]",
],
violet: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90",
"[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]",
],
purple: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90",
"[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]",
],
fuchsia: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90",
"[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]",
],
pink: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90",
"[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]",
],
rose: [
"text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90",
"[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]",
],
},
};
type ButtonProps = (
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
| { color?: never; outline: true; plain?: never }
| { color?: never; outline?: never; plain: true }
) & { className?: string; children: React.ReactNode } & (
| Omit<Headless.ButtonProps, "as" | "className">
| Omit<React.ComponentPropsWithoutRef<typeof Link>, "className">
);
export const Button = forwardRef(function Button(
{ color, outline, plain, className, children, ...props }: ButtonProps,
ref: React.ForwardedRef<HTMLElement>,
) {
let classes = clsx(
className,
styles.base,
outline
? styles.outline
: plain
? styles.plain
: clsx(styles.solid, styles.colors[color ?? "dark/zinc"]),
);
return "href" in props ? (
<Link
{...(props as Omit<
React.ComponentPropsWithoutRef<typeof Link>,
"className"
>)}
className={classes}
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
>
<TouchTarget>{children}</TouchTarget>
</Link>
) : (
<Headless.Button
{...(props as Omit<Headless.ButtonProps, "as" | "className">)}
className={clsx(classes, "cursor-pointer")}
ref={ref as React.ForwardedRef<HTMLButtonElement>}
>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
);
});
/**
* Expand the hit area to at least 44×44px on touch devices
*/
export function TouchTarget({ children }: { children: React.ReactNode }) {
return (
<>
<span
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
{children}
</>
);
}
import { clsx } from "clsx";
import { XMarkIcon } from "@heroicons/react/20/solid";
const colors = {
red: "bg-red-50/80 dark:bg-red-950/50",
neutral: "bg-neutral-50/80 dark:bg-neutral-800/50",
amber: "bg-amber-50/80 dark:bg-amber-950/50",
blue: "bg-blue-50/80 dark:bg-blue-950/50",
green: "bg-green-50/80 dark:bg-green-950/50",
zinc: "bg-zinc-50/80 dark:bg-zinc-800/50",
};
const textColors = {
red: "text-red-600 dark:text-red-400",
neutral: "text-neutral-600 dark:text-neutral-400",
amber: "text-amber-600 dark:text-amber-400",
blue: "text-blue-600 dark:text-blue-400",
green: "text-green-600 dark:text-green-400",
zinc: "text-zinc-600 dark:text-zinc-400",
};
const dismissButtonColors = {
red: "text-red-400 hover:bg-red-100/50 hover:text-red-600 dark:text-red-500 dark:hover:bg-red-900/30 dark:hover:text-red-300",
neutral:
"text-neutral-400 hover:bg-neutral-200/50 hover:text-neutral-600 dark:text-neutral-500 dark:hover:bg-neutral-700/50 dark:hover:text-neutral-300",
amber:
"text-amber-400 hover:bg-amber-100/50 hover:text-amber-600 dark:text-amber-500 dark:hover:bg-amber-900/30 dark:hover:text-amber-300",
blue: "text-blue-400 hover:bg-blue-100/50 hover:text-blue-600 dark:text-blue-500 dark:hover:bg-blue-900/30 dark:hover:text-blue-300",
green:
"text-green-400 hover:bg-green-100/50 hover:text-green-600 dark:text-green-500 dark:hover:bg-green-900/30 dark:hover:text-green-300",
zinc: "text-zinc-400 hover:bg-zinc-200/50 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700/50 dark:hover:text-zinc-300",
};
export interface DisplayAction {
label: string;
onClick?: () => void;
href?: string;
disabled?: boolean;
loading?: boolean;
gradientColors?: string;
}
interface DisplayProps {
message: string;
variant?: keyof typeof colors;
onDismiss?: () => void;
action?: DisplayAction;
className?: string;
}
export const Display = ({
message,
variant = "neutral",
onDismiss,
action,
className,
}: DisplayProps) => {
const ActionButton = ({ action }: { action: DisplayAction }) => {
const buttonClass =
"px-3 py-1.5 text-xs font-medium text-white bg-zinc-900 border border-zinc-950/90 rounded-full shadow-sm disabled:opacity-50 disabled:cursor-not-allowed dark:text-zinc-950 dark:bg-white dark:border-zinc-950/10 cursor-pointer hover:bg-zinc-800 dark:hover:bg-neutral-100";
const content = (
<span>{action.loading ? `${action.label}...` : action.label}</span>
);
if (action.href) {
return (
<a
href={action.href}
target="_blank"
rel="noopener noreferrer"
className={buttonClass}
>
{content}
</a>
);
}
return (
<button
onClick={action.onClick}
disabled={action.disabled}
className={buttonClass}
>
{content}
</button>
);
};
return (
<div
className={clsx(
"mx-auto flex w-full max-w-[730px] items-center justify-between rounded-2xl px-4 py-3 text-sm transition-all duration-200 backdrop-blur-sm",
colors[variant],
className,
)}
>
<div className="flex items-center space-x-3 select-text">
<span className={clsx("leading-relaxed", textColors[variant])}>
{message}
</span>
</div>
<div className="flex items-center space-x-3">
{action && <ActionButton action={action} />}
{onDismiss && (
<button
onClick={onDismiss}
className={clsx(
"rounded-full p-1.5 cursor-pointer",
dismissButtonColors[variant],
)}
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
};
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import type React from "react";
export function Fieldset({
className,
...props
}: { className?: string } & Omit<Headless.FieldsetProps, "as" | "className">) {
return (
<Headless.Fieldset
{...props}
className={clsx(
className,
"*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6",
)}
/>
);
}
export function Legend({
className,
...props
}: { className?: string } & Omit<Headless.LegendProps, "as" | "className">) {
return (
<Headless.Legend
data-slot="legend"
{...props}
className={clsx(
className,
"text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white",
)}
/>
);
}
export function FieldGroup({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) {
return (
<div
data-slot="control"
{...props}
className={clsx(className, "space-y-8")}
/>
);
}
export function Field({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, "as" | "className">) {
return (
<Headless.Field
{...props}
className={clsx(
className,
"[&>[data-slot=label]+[data-slot=control]]:mt-3",
"[&>[data-slot=label]+[data-slot=description]]:mt-1",
"[&>[data-slot=description]+[data-slot=control]]:mt-3",
"[&>[data-slot=control]+[data-slot=description]]:mt-3",
"[&>[data-slot=control]+[data-slot=error]]:mt-3",
"*:data-[slot=label]:font-medium",
)}
/>
);
}
export function Label({
className,
...props
}: { className?: string } & Omit<Headless.LabelProps, "as" | "className">) {
return (
<Headless.Label
data-slot="label"
{...props}
className={clsx(
className,
"text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white",
)}
/>
);
}
export function Description({
className,
...props
}: { className?: string } & Omit<
Headless.DescriptionProps,
"as" | "className"
>) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(
className,
"text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400",
)}
/>
);
}
export function ErrorMessage({
className,
...props
}: { className?: string } & Omit<
Headless.DescriptionProps,
"as" | "className"
>) {
return (
<Headless.Description
data-slot="error"
{...props}
className={clsx(
className,
"text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500",
)}
/>
);
}
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import React, { forwardRef } from "react";
export function InputGroup({
children,
}: React.ComponentPropsWithoutRef<"span">) {
return (
<span
data-slot="control"
className={clsx(
"relative isolate block",
"has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8",
"*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4",
"[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5",
"*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400",
)}
>
{children}
</span>
);
}
const dateTypes = ["date", "datetime-local", "month", "time", "week"];
type DateType = (typeof dateTypes)[number];
export const Input = forwardRef(function Input(
{
className,
...props
}: {
className?: string;
type?:
| "email"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "url"
| DateType;
} & Omit<Headless.InputProps, "as" | "className">,
ref: React.ForwardedRef<HTMLInputElement>,
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
"relative block w-full",
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
"before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm",
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
"dark:before:hidden",
// Focus ring
"after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500",
// Disabled state
"has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none",
// Invalid state
"has-data-invalid:before:shadow-red-500/10",
])}
>
<Headless.Input
ref={ref}
{...props}
className={clsx([
// Date classes
props.type &&
dateTypes.includes(props.type) && [
"[&::-webkit-datetime-edit-fields-wrapper]:p-0",
"[&::-webkit-date-and-time-value]:min-h-[1.5em]",
"[&::-webkit-datetime-edit]:inline-flex",
"[&::-webkit-datetime-edit]:p-0",
"[&::-webkit-datetime-edit-year-field]:p-0",
"[&::-webkit-datetime-edit-month-field]:p-0",
"[&::-webkit-datetime-edit-day-field]:p-0",
"[&::-webkit-datetime-edit-hour-field]:p-0",
"[&::-webkit-datetime-edit-minute-field]:p-0",
"[&::-webkit-datetime-edit-second-field]:p-0",
"[&::-webkit-datetime-edit-millisecond-field]:p-0",
"[&::-webkit-datetime-edit-meridiem-field]:p-0",
],
// Basic layout
"relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]",
// Typography
"text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white",
// Border
"border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20",
// Background color
"bg-transparent dark:bg-white/5",
// Hide default focus styles
"focus:outline-hidden",
// Invalid state
"data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500",
// Disabled state
"data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15",
// System icons
"dark:scheme-dark",
])}
/>
</span>
);
});
import * as Headless from "@headlessui/react";
import {
Link as RouterLink,
type LinkProps as RouterLinkProps,
} from "@tanstack/react-router";
import React, { forwardRef } from "react";
export const Link = forwardRef(function Link(
props: RouterLinkProps & React.ComponentPropsWithoutRef<"a">,
ref: React.ForwardedRef<HTMLAnchorElement>,
) {
return (
<Headless.DataInteractive>
<RouterLink {...props} ref={ref} />
</Headless.DataInteractive>
);
});
import * as React from "react";
export interface SliderProps {
label?: string;
options?: { value: number; label: string }[];
value?: number;
onChange?: (value: number) => void;
className?: string;
}
const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
({ label, options, value = 0, onChange }, ref) => {
const [selectedValue, setSelectedValue] = React.useState(value);
const [isDragging, setIsDragging] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
// Update internal state when value prop changes
React.useEffect(() => {
setSelectedValue(value);
}, [value]);
const handleClick = (optionValue: number) => {
setSelectedValue(optionValue);
onChange?.(optionValue);
};
const getClosestOption = (clientX: number) => {
if (!containerRef.current || !options) return null;
const rect = containerRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
const width = rect.width;
const segmentWidth = width / (options.length - 1);
let closestIndex = Math.round(relativeX / segmentWidth);
closestIndex = Math.max(0, Math.min(closestIndex, options.length - 1));
return options[closestIndex].value;
};
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
e.preventDefault();
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const closestValue = getClosestOption(e.clientX);
if (closestValue !== null && closestValue !== selectedValue) {
setSelectedValue(closestValue);
// Don't call onChange during drag, just update visual state
}
};
const handleMouseUp = () => {
if (isDragging) {
// Call onChange with the final value when drag ends
onChange?.(selectedValue);
}
setIsDragging(false);
};
React.useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, selectedValue]);
if (!options) {
return null;
}
return (
<div className="space-y-2" ref={ref}>
{label && <label className="text-sm font-medium">{label}</label>}
<div className="relative">
<div className="absolute top-[9px] left-2 right-2 h-1 bg-neutral-200 dark:bg-neutral-700 pointer-events-none rounded-full" />
<div className="flex justify-between" ref={containerRef}>
{options.map((option) => (
<div key={option.value} className="flex flex-col items-center">
<button
onClick={() => handleClick(option.value)}
onMouseDown={handleMouseDown}
className="relative px-3 py-6 -mx-3 -my-6 z-10 cursor-pointer"
>
<div className="relative w-5 h-5 flex items-center justify-center">
{selectedValue === option.value && (
<div className="w-4 h-4 bg-white dark:bg-white border border-neutral-400 dark:border-neutral-500 rounded-full cursor-grab active:cursor-grabbing" />
)}
</div>
</button>
<div className="text-xs mt text-neutral-500 dark:text-neutral-400">
{option.label}
</div>
</div>
))}
</div>
</div>
</div>
);
},
);
Slider.displayName = "Slider";
export { Slider };
import * as Headless from "@headlessui/react";
import clsx from "clsx";
import type React from "react";
export function SwitchGroup({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
"space-y-3 **:data-[slot=label]:font-normal",
// With descriptions
"has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium",
)}
/>
);
}
export function SwitchField({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, "as" | "className">) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
"grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]",
// Control layout
"*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5",
// Label layout
"*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1",
// Description layout
"*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2",
// With description
"has-data-[slot=description]:**:data-[slot=label]:font-medium",
)}
/>
);
}
const colors = {
"dark/zinc": [
"[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25",
"[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90",
],
"dark/white": [
"[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]",
"[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]",
],
dark: [
"[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15",
"[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]",
],
zinc: [
"[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]",
"[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90",
],
white: [
"[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]",
"[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]",
],
red: [
"[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20",
],
orange: [
"[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20",
],
amber: [
"[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]",
"[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]",
],
yellow: [
"[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]",
"[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]",
],
lime: [
"[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]",
"[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]",
],
green: [
"[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20",
],
emerald: [
"[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20",
],
teal: [
"[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20",
],
cyan: [
"[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]",
"[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]",
],
sky: [
"[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20",
],
blue: [
"[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20",
],
indigo: [
"[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20",
],
violet: [
"[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20",
],
purple: [
"[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20",
],
fuchsia: [
"[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20",
],
pink: [
"[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20",
],
rose: [
"[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]",
"[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20",
],
};
type Color = keyof typeof colors;
export function Switch({
color = "dark/zinc",
className,
...props
}: {
color?: Color;
className?: string;
} & Omit<Headless.SwitchProps, "as" | "className" | "children">) {
return (
<Headless.Switch
data-slot="control"
{...props}
className={clsx(
className,
// Base styles
"group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8",
// Transitions
"transition duration-0 ease-in-out data-changing:duration-200",
// Outline and background color in forced-colors mode so switch is still visible
"forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]",
// Unchecked
"bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15",
// Checked
"data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)",
// Focus
"focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
// Hover
"data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)",
"dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)",
// Disabled
"data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5",
"dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15",
// Color specific styles
colors[color],
)}
>
<span
aria-hidden="true"
className={clsx(
// Basic layout
"pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5",
// Transition
"translate-x-0 transition duration-200 ease-in-out",
// Invisible border so the switch is still visible in forced-colors mode
"border border-transparent",
// Unchecked
"bg-white shadow-sm ring-1 ring-black/5",
// Checked
"group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)",
"group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3",
// Disabled
"group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5",
)}
/>
</Headless.Switch>
);
}
import clsx from "clsx";
import { Link } from "./link";
export function Text({
className,
...props
}: React.ComponentPropsWithoutRef<"p">) {
return (
<p
data-slot="text"
{...props}
className={clsx(
className,
"text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400",
)}
/>
);
}
export function TextLink({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Link>) {
return (
<Link
{...props}
className={clsx(
className,
"text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white",
)}
/>
);
}
export function Strong({
className,
...props
}: React.ComponentPropsWithoutRef<"strong">) {
return (
<strong
{...props}
className={clsx(className, "font-medium text-zinc-950 dark:text-white")}
/>
);
}
export function Code({
className,
...props
}: React.ComponentPropsWithoutRef<"code">) {
return (
<code
{...props}
className={clsx(
className,
"rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white",
)}
/>
);
}
import {
createContext,
useContext,
useState,
useMemo,
type ReactNode,
type Dispatch,
type SetStateAction,
} from "react";
import { DownloadEvent } from "@/gotypes";
interface StreamingContextType {
streamingChatIds: Set<string>;
setStreamingChatIds: Dispatch<SetStateAction<Set<string>>>;
loadingChats: Set<string>;
setLoadingChats: Dispatch<SetStateAction<Set<string>>>;
abortControllers: Map<string, AbortController>;
setAbortControllers: Dispatch<SetStateAction<Map<string, AbortController>>>;
downloadProgress: Map<string, DownloadEvent>;
setDownloadProgress: Dispatch<SetStateAction<Map<string, DownloadEvent>>>;
}
const StreamingContext = createContext<StreamingContextType | undefined>(
undefined,
);
export function StreamingProvider({ children }: { children: ReactNode }) {
const [streamingChatIds, setStreamingChatIds] = useState<Set<string>>(
new Set(),
);
const [loadingChats, setLoadingChats] = useState<Set<string>>(new Set());
const [abortControllers, setAbortControllers] = useState<
Map<string, AbortController>
>(new Map());
const [downloadProgress, setDownloadProgress] = useState<
Map<string, DownloadEvent>
>(new Map());
const contextValue = useMemo(
() => ({
streamingChatIds,
setStreamingChatIds,
loadingChats,
setLoadingChats,
abortControllers,
setAbortControllers,
downloadProgress,
setDownloadProgress,
}),
[streamingChatIds, loadingChats, abortControllers, downloadProgress],
);
return (
<StreamingContext.Provider value={contextValue}>
{children}
</StreamingContext.Provider>
);
}
export function useStreamingContext() {
const context = useContext(StreamingContext);
if (context === undefined) {
throw new Error(
"useStreamingContext must be used within a StreamingProvider",
);
}
return context;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getChats, getChat, sendMessage, type ChatEventUnion } from "../api";
import { Chat, ErrorEvent, Model } from "@/gotypes";
import { Message } from "@/gotypes";
import { useSelectedModel } from "./useSelectedModel";
import { createQueryBatcher } from "./useQueryBatcher";
import { useRefetchModels } from "./useModels";
import { useStreamingContext } from "@/contexts/StreamingContext";
import { useSettings } from "./useSettings";
export const useChats = () => {
return useQuery({
queryKey: ["chats"],
queryFn: getChats,
});
};
export const useChat = (chatId: string) => {
const queryClient = useQueryClient();
const { streamingChatIds } = useStreamingContext();
return useQuery({
queryKey: ["chat", chatId],
queryFn: async () => {
// Check if we have optimistic data and this chat is currently streaming
const existingData = queryClient.getQueryData<{ chat: Chat }>([
"chat",
chatId,
]);
const isStreaming = streamingChatIds.has(chatId);
const response = await getChat(chatId);
// If we have existing optimistic data with more messages than the server
// and this chat is currently streaming, preserve the optimistic data
if (existingData && isStreaming) {
const existingCount = existingData.chat.messages?.length || 0;
const serverCount = response.chat?.messages?.length || 0;
if (existingCount > serverCount) {
return existingData;
}
}
// Process messages to ensure tool calls are properly structured
if (response.chat && response.chat.messages) {
response.chat.messages = response.chat.messages.map((msg) => {
// If this is a tool message without tool_calls but has content about a tool call
if (
msg.role === "tool" &&
(!msg.tool_calls || msg.tool_calls.length === 0)
) {
// Check if content indicates this is a tool call (not a tool result)
const toolCallMatch = msg.content.match(/Tool (\w+) called/);
if (toolCallMatch) {
// This is likely a tool call message that lost its structure
// For now, we'll leave it as-is but could enhance this later
// to parse the content and reconstruct the tool_calls array
}
}
return msg;
});
}
return response;
},
enabled: !!chatId && chatId !== "new",
staleTime: 1500,
});
};
export const useStaleModels = () => {
return useQuery({
queryKey: ["staleModels"],
queryFn: () => new Map<string, boolean>(),
initialData: new Map<string, boolean>(),
staleTime: Infinity,
gcTime: Infinity,
});
};
export const useDismissedStaleModels = () => {
return useQuery({
queryKey: ["dismissedStaleModels"],
queryFn: () => new Set<string>(),
initialData: new Set<string>(),
staleTime: Infinity,
gcTime: Infinity,
});
};
export const useChatError = (chatId: string) => {
return useQuery({
queryKey: ["chatError", chatId],
queryFn: () => null as ErrorEvent | null,
initialData: null,
staleTime: Infinity,
gcTime: 1000 * 60 * 5, // Keep in cache for 5 minutes
});
};
export const useIsStreaming = (chatId: string) => {
const { streamingChatIds } = useStreamingContext();
return streamingChatIds.has(chatId);
};
export const useDownloadProgress = (chatId: string) => {
const { downloadProgress } = useStreamingContext();
return downloadProgress.get(chatId);
};
export const useIsModelStale = (modelName: string) => {
const { data: staleModels } = useStaleModels();
return staleModels?.get(modelName) || false;
};
export const useShouldShowStaleDisplay = (model: Model | null) => {
const isStale = useIsModelStale(model?.model || "");
const { data: dismissedModels } = useDismissedStaleModels();
const {
settings: { airplaneMode },
} = useSettings();
if (model?.isCloud() && !airplaneMode) {
return false;
}
return isStale && !dismissedModels?.has(model?.model || "");
};
export const useDismissStaleModel = () => {
const queryClient = useQueryClient();
const refetchModels = useRefetchModels();
return (modelName: string) => {
const currentDismissedModels =
queryClient.getQueryData<Set<string>>(["dismissedStaleModels"]) ||
new Set();
const newSet = new Set(currentDismissedModels);
newSet.add(modelName);
queryClient.setQueryData(["dismissedStaleModels"], newSet);
refetchModels();
};
};
// Helper hook to check if we should show loading bar (streaming but no first token yet)
export const useIsWaitingForLoad = (chatId: string) => {
const { streamingChatIds, loadingChats } = useStreamingContext();
const { selectedModel } = useSelectedModel();
const { data: chatsData } = useChats();
const queryClient = useQueryClient();
// Basic check: is this chat streaming but hasn't loaded yet?
const isWaitingForLoad =
streamingChatIds.has(chatId) && !loadingChats.has(chatId);
if (!isWaitingForLoad || !selectedModel || !chatsData?.chatInfos) {
return isWaitingForLoad;
}
// Find the most recent chat that isn't the current one
const sortedChats = [...chatsData.chatInfos].sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
);
const mostRecentOtherChat = sortedChats.find((chat) => chat.id !== chatId);
if (!mostRecentOtherChat) {
return isWaitingForLoad;
}
// Check if the most recent chat used the same model within 5 minutes
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
const wasRecentlyActive =
mostRecentOtherChat.updatedAt.getTime() > fiveMinutesAgo;
if (!wasRecentlyActive) {
return isWaitingForLoad;
}
const recentChatData = queryClient.getQueryData<{ chat: Chat }>([
"chat",
mostRecentOtherChat.id,
]);
if (
!recentChatData?.chat?.messages ||
recentChatData.chat.messages.length === 0
) {
return isWaitingForLoad;
}
const lastAssistantMessage = [...recentChatData.chat.messages]
.reverse()
.find((msg) => msg.role === "assistant" && msg.model);
if (!lastAssistantMessage?.model) {
return isWaitingForLoad;
}
// If the same model was used recently, skip the loading bar
const isSameModel = lastAssistantMessage.model === selectedModel.model;
return isWaitingForLoad && !isSameModel;
};
export const useSendMessage = (chatId: string) => {
let updatableChatId = chatId;
const queryClient = useQueryClient();
const { selectedModel } = useSelectedModel();
const {
setStreamingChatIds,
loadingChats,
setLoadingChats,
setAbortControllers,
setDownloadProgress,
} = useStreamingContext();
const cleanupStreaming = (id: string) => {
setStreamingChatIds((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
setAbortControllers((prev) => {
const newMap = new Map(prev);
newMap.delete(id);
return newMap;
});
setDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.delete(id);
return newMap;
});
};
return useMutation({
mutationKey: ["sendMessage", chatId],
onSuccess: () => {
cleanupStreaming(updatableChatId);
},
onError: (error) => {
console.error("error mutating sendMessage", error);
cleanupStreaming(updatableChatId);
},
mutationFn: async ({
message,
attachments,
index,
webSearch,
fileTools,
forceUpdate,
think,
onChatEvent,
}: {
message: string;
attachments?: Array<{ filename: string; data: Uint8Array }>;
index?: number;
webSearch?: boolean;
fileTools?: boolean;
forceUpdate?: boolean;
think?: boolean | string;
onChatEvent?: (event: ChatEventUnion) => void;
}) => {
// For existing chats, set streaming state and add optimistic user message
if (chatId !== "new") {
setStreamingChatIds((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.add(chatId);
return newSet;
});
queryClient.cancelQueries({ queryKey: ["chat", chatId] });
// Only add optimistic message for non-empty messages
if (message.trim() !== "") {
// Optimistically add the user message
queryClient.setQueryData(
["chat", chatId],
(old: { chat: Chat } | undefined) => {
if (!old) return old;
const newMessage = new Message({
role: "user",
content: message,
attachments: attachments,
});
let messages = old.chat.messages || [];
// If editing a message (index provided), truncate messages array
if (
index !== undefined &&
index >= 0 &&
index < messages.length
) {
messages = messages.slice(0, index);
}
return {
...old,
chat: new Chat({
...old.chat,
messages: [...messages, newMessage],
}),
};
},
);
}
}
if (!selectedModel) {
throw new Error("No model selected");
}
const effectiveModel = new Model({
model: selectedModel.model,
digest: selectedModel.digest,
modified_at: selectedModel.modified_at,
});
const abortController = new AbortController();
setAbortControllers((prev) => {
const newMap = new Map(prev);
newMap.set(updatableChatId, abortController);
return newMap;
});
const events = sendMessage(
chatId,
message,
effectiveModel,
attachments,
abortController.signal,
index,
webSearch,
fileTools,
forceUpdate,
think,
);
let currentChatId = chatId;
let isCancelled = false;
// Listen for abort signal to set cancelled flag
abortController.signal.addEventListener("abort", () => {
isCancelled = true;
});
// Create batcher for streaming updates with smoother intervals, prevents state update depth being exceeded
// and allows for smoother updates at high frame rates
let batcher = createQueryBatcher<{ chat: Chat }>(
queryClient,
["chat", currentChatId],
{ batchInterval: 4, immediateFirst: true }, // ~250fps for smoother updates
);
for await (const event of events) {
// If cancelled, continue draining the stream but don't update UI
if (isCancelled) {
continue;
}
// download events don't count as loaded
// TODO(jmorganca): loading should potentially be an event instead of
// reducing it this way
if (
event.eventName !== "download" &&
!loadingChats.has(currentChatId)
) {
// If this is the first time loading this chat, mark it as loaded
setLoadingChats((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.add(currentChatId);
return newSet;
});
}
switch (event.eventName) {
case "chat": {
// Update the current chat data with streaming content
batcher.scheduleBatch((old: { chat: Chat } | undefined) => {
if (!old) return old;
const existingMessages = old.chat.messages || [];
const newMessages = [...existingMessages];
// Find or create the assistant message
let lastMessage = newMessages[newMessages.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
newMessages.push(
new Message({
role: "assistant",
content: "",
thinking: "",
model: effectiveModel,
}),
);
lastMessage = newMessages[newMessages.length - 1];
}
// Update the last message with new content
if (lastMessage) {
const updatedContent =
(lastMessage.content || "") + (event.content || "");
const updatedThinking =
(lastMessage.thinking || "") + (event.thinking || "");
const updatedMessage = new Message({
...lastMessage,
content: updatedContent,
thinking: updatedThinking,
});
if (event.thinkingTimeStart) {
updatedMessage.thinkingTimeStart = event.thinkingTimeStart;
}
if (event.thinkingTimeEnd) {
updatedMessage.thinkingTimeEnd = event.thinkingTimeEnd;
}
newMessages[newMessages.length - 1] = updatedMessage;
}
return {
...old,
chat: new Chat({
...old.chat,
messages: newMessages,
}),
};
});
break;
}
case "thinking": {
// Handle thinking content
batcher.scheduleBatch((old: { chat: Chat } | undefined) => {
if (!old) return old;
const existingMessages = old.chat.messages || [];
const newMessages = [...existingMessages];
// Find or create the assistant message
let lastMessage = newMessages[newMessages.length - 1];
if (!lastMessage || lastMessage.role !== "assistant") {
newMessages.push(
new Message({
role: "assistant",
content: "",
thinking: "",
model: effectiveModel,
}),
);
lastMessage = newMessages[newMessages.length - 1];
}
// Update the last message with new thinking content
if (lastMessage) {
const updatedThinking =
(lastMessage.thinking || "") + (event.thinking || "");
const updatedMessage = new Message({
...lastMessage,
thinking: updatedThinking,
});
if (event.thinkingTimeStart) {
updatedMessage.thinkingTimeStart = event.thinkingTimeStart;
}
newMessages[newMessages.length - 1] = updatedMessage;
}
return {
...old,
chat: new Chat({
...old.chat,
messages: newMessages,
}),
};
});
break;
}
case "tool_call": {
// Handle tool call events - these are now mostly handled by assistant_with_tools
// but kept for backward compatibility, potentially still good for normal tool calling models
queryClient.setQueryData(
["chat", currentChatId],
(old: { chat: Chat } | undefined) => {
if (!old) return old;
const existingMessages = old.chat.messages || [];
const newMessages = [...existingMessages];
// Add tool call message
if (event.toolCall) {
newMessages.push(
new Message({
role: "tool",
content: `Tool ${event.toolCall.function.name} called`,
tool_calls: [event.toolCall],
thinkingTimeStart: event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
}),
);
}
return {
...old,
chat: new Chat({
...old.chat,
messages: newMessages,
}),
};
},
);
break;
}
case "assistant_with_tools": {
// Handle assistant messages that include tool calls
queryClient.setQueryData(
["chat", currentChatId],
(old: { chat: Chat } | undefined) => {
if (!old) return old;
const existingMessages = old.chat.messages || [];
const newMessages = [...existingMessages];
// Find the last assistant message and update it with tool calls
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
// Update existing assistant message with tool calls
const updatedMessage = new Message({
...lastMessage,
content: lastMessage.content + (event.content || ""),
thinking: lastMessage.thinking + (event.thinking || ""),
tool_calls: event.toolCalls,
thinkingTimeStart:
lastMessage.thinkingTimeStart || event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
model: selectedModel,
});
newMessages[newMessages.length - 1] = updatedMessage;
} else {
// No existing assistant message, create new one
newMessages.push(
new Message({
role: "assistant",
content: event.content,
thinking: event.thinking,
tool_calls: event.toolCalls,
thinkingTimeStart: event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
model: selectedModel,
}),
);
}
return {
...old,
chat: new Chat({
...old.chat,
messages: newMessages,
}),
};
},
);
break;
}
case "tool_result": {
// Handle tool result events
queryClient.setQueryData(
["chat", currentChatId],
(old: { chat: Chat } | undefined) => {
if (!old) return old;
const existingMessages = old.chat.messages || [];
const newMessages = [...existingMessages];
newMessages.push(
Object.assign(
new Message({
role: "tool",
content: event.content,
thinkingTimeStart: event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
}),
{
tool_result: (event as any).toolResultData,
...((event as any).toolName
? { tool_name: (event as any).toolName }
: {}),
},
),
);
return {
...old,
chat: new Chat({
...old.chat,
messages: newMessages,
browser_state: event.toolState ?? old.chat.browser_state,
}),
};
},
);
break;
}
case "download": {
setDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.set(currentChatId, event);
return newMap;
});
if (event.done && selectedModel) {
const currentStaleModels =
queryClient.getQueryData<Map<string, boolean>>([
"staleModels",
]) || new Map();
const newStaleMap = new Map(currentStaleModels);
newStaleMap.delete(selectedModel.model);
queryClient.setQueryData(["staleModels"], newStaleMap);
queryClient.invalidateQueries({ queryKey: ["models"] });
}
break;
}
case "error": {
// Clean up streaming state
setStreamingChatIds((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.delete(currentChatId);
return newSet;
});
setDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.delete(currentChatId);
return newMap;
});
// Set error using separate React Query cache
queryClient.setQueryData(
["chatError", currentChatId],
event as ErrorEvent,
);
break;
}
case "done":
// TODO(drifkin): update the chat with the thinking time for cases
// where there is thinking content, but no other content (which
// should be very rare)
setStreamingChatIds((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.delete(currentChatId);
return newSet;
});
// Clear download progress when streaming is done
setDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.delete(currentChatId);
return newMap;
});
// Ensure chat is fresh for next fetch
queryClient.invalidateQueries({
queryKey: ["chat", currentChatId],
});
break;
case "chat_created": {
if (!event.chatId) break;
const newId = event.chatId;
updatableChatId = newId;
setStreamingChatIds((prev: Set<string>) => {
const newSet = new Set(prev);
newSet.add(newId);
return newSet;
});
setAbortControllers((prev) => {
const newMap = new Map(prev);
const controller = newMap.get(chatId);
if (controller) {
newMap.delete(chatId);
newMap.set(newId, controller);
}
return newMap;
});
// Flush current batcher and create new one for the new chat ID
batcher.flushBatch();
batcher.cleanup();
currentChatId = newId;
batcher = createQueryBatcher<{ chat: Chat }>(
queryClient,
["chat", currentChatId],
{ batchInterval: 4, immediateFirst: true },
);
// Create initial chat data for the new chat
queryClient.setQueryData(["chat", newId], {
chat: new Chat({
id: newId,
model: effectiveModel,
messages: [
new Message({
role: "user",
content: message,
attachments: attachments,
}),
],
}),
});
// Cancel the old "new" chat query if it exists
if (chatId === "new") {
queryClient.cancelQueries({ queryKey: ["chat", "new"] });
}
// Invalidate chats list to include the new chat
queryClient.invalidateQueries({ queryKey: ["chats"] });
break;
}
}
onChatEvent?.(event);
}
// Flush any remaining batched updates and cleanup
batcher.flushBatch();
batcher.cleanup();
},
});
};
export const useCancelMessage = () => {
const {
abortControllers,
setStreamingChatIds,
setAbortControllers,
setDownloadProgress,
} = useStreamingContext();
return (chatId: string) => {
const controller = abortControllers.get(chatId);
if (controller) {
controller.abort();
setStreamingChatIds(
(prev) => new Set([...prev].filter((id) => id !== chatId)),
);
setAbortControllers((prev) => {
const newMap = new Map(prev);
newMap.delete(chatId);
return newMap;
});
setDownloadProgress((prev) => {
const newMap = new Map(prev);
newMap.delete(chatId);
return newMap;
});
}
};
};
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteChat } from "@/api";
import { useNavigate } from "@tanstack/react-router";
export function useDeleteChat() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: (chatId: string) => deleteChat(chatId),
onSuccess: (_, chatId) => {
// If we're currently viewing the deleted chat, navigate away
const currentPath = window.location.pathname;
if (currentPath === `/c/${chatId}`) {
navigate({ to: "/c/$chatId", params: { chatId: "new" } });
}
queryClient.invalidateQueries({ queryKey: ["chats"] });
},
onError: (error, chatId) => {
console.error("Failed to delete chat:", chatId, error);
},
});
}
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { pullModel } from "@/api";
import { useSelectedModel } from "./useSelectedModel";
import { useSettings } from "./useSettings";
interface DownloadProgress {
status: string;
digest?: string;
total?: number;
completed?: number;
done?: boolean;
}
export function useDownloadModel(chatId?: string) {
const queryClient = useQueryClient();
const { selectedModel } = useSelectedModel(chatId);
const { setSettings } = useSettings();
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(null);
const [abortController, setAbortController] =
useState<AbortController | null>(null);
const [downloadingChatIds, setDownloadingChatIds] = useState<Set<string>>(
new Set(),
);
const mutation = useMutation({
mutationFn: async (modelName: string) => {
const controller = new AbortController();
setAbortController(controller);
setDownloadProgress({ status: "Starting download..." });
if (chatId) {
setDownloadingChatIds((prev) => new Set(prev).add(chatId));
}
try {
for await (const progress of pullModel(modelName, controller.signal)) {
setDownloadProgress(progress);
if (progress.status === "success") {
// Update selected model to indicate it's now available locally
if (selectedModel && selectedModel.model === modelName) {
setSettings({ SelectedModel: modelName });
}
// Invalidate models query to refresh the list
await queryClient.invalidateQueries({ queryKey: ["models"] });
break;
}
}
} finally {
setAbortController(null);
if (chatId) {
setDownloadingChatIds((prev) => {
const newSet = new Set(prev);
newSet.delete(chatId);
return newSet;
});
}
}
},
onSuccess: () => {
setDownloadProgress(null);
if (chatId) {
setDownloadingChatIds((prev) => {
const newSet = new Set(prev);
newSet.delete(chatId);
return newSet;
});
}
},
onError: (error: Error) => {
const status =
error.name === "AbortError" ? "Download cancelled" : "Download failed";
setDownloadProgress({ status, done: true });
// Clear error message after delay
const delay = error.name === "AbortError" ? 1500 : 3000;
setTimeout(() => {
setDownloadProgress(null);
if (chatId) {
setDownloadingChatIds((prev) => {
const newSet = new Set(prev);
newSet.delete(chatId);
return newSet;
});
}
}, delay);
},
});
const cancelDownload = () => {
if (abortController) {
abortController.abort();
setAbortController(null);
if (chatId) {
setDownloadingChatIds((prev) => {
const newSet = new Set(prev);
newSet.delete(chatId);
return newSet;
});
}
}
};
return {
downloadModel: mutation.mutate,
isDownloading:
mutation.isPending && chatId ? downloadingChatIds.has(chatId) : false,
downloadProgress:
chatId && downloadingChatIds.has(chatId) ? downloadProgress : null,
error: mutation.error,
cancelDownload,
};
}
import { useQuery } from "@tanstack/react-query";
import { fetchHealth } from "@/api";
export function useHealth() {
const healthQuery = useQuery({
queryKey: ["health"],
queryFn: fetchHealth,
refetchInterval: (query) => {
// If the server is not healthy, poll every 10ms
// Once healthy, stop polling
return query.state.data === false ? 10 : false;
},
refetchIntervalInBackground: true,
retry: false, // Don't retry, just return false
staleTime: 0, // Always consider stale so we keep polling
});
return {
isHealthy: healthQuery.data ?? false,
isChecking: healthQuery.isLoading,
};
}
import {
useRef,
useCallback,
useEffect,
useLayoutEffect,
useState,
useMemo,
} from "react";
import type { Message } from "@/gotypes";
// warning: this file is all claude code, needs to be looked into more closely
interface UseMessageAutoscrollOptions {
messages: Message[];
isStreaming: boolean;
chatId: string;
}
interface MessageAutoscrollBehavior {
handleNewUserMessage: () => void;
containerRef: React.RefObject<HTMLElement | null>;
spacerHeight: number;
}
export const useMessageAutoscroll = ({
messages,
isStreaming,
chatId,
}: UseMessageAutoscrollOptions): MessageAutoscrollBehavior => {
const containerRef = useRef<HTMLElement | null>(null);
const pendingScrollToUserMessage = useRef(false);
const [spacerHeight, setSpacerHeight] = useState(0);
const lastScrollHeightRef = useRef(0);
const lastScrollTopRef = useRef(0);
const [isActiveInteraction, setIsActiveInteraction] = useState(false);
const [hasSubmittedMessage, setHasSubmittedMessage] = useState(false);
const prevChatIdRef = useRef<string>(chatId);
// Find the last user message index from React state
const getLastUserMessageIndex = useCallback(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
return i;
}
}
return -1;
}, [messages]);
const scrollToMessage = useCallback((messageIndex: number) => {
if (!containerRef.current || messageIndex < 0) {
return;
}
const container = containerRef.current;
// select the exact element by its data-message-index to avoid index mismatches
const targetElement = container.querySelector(
`[data-message-index="${messageIndex}"]`,
) as HTMLElement | null;
if (!targetElement) return;
const containerHeight = container.clientHeight;
const containerStyle = window.getComputedStyle(container);
const paddingTop = parseFloat(containerStyle.paddingTop) || 0;
const scrollHeight = container.scrollHeight;
const messageHeight = targetElement.offsetHeight;
// Check if the message is large, which is 70% of the container height
const isLarge = messageHeight > containerHeight * 0.7;
let targetPosition: number = targetElement.offsetTop - paddingTop; // default to scrolling the message to the top of the window
if (isLarge) {
// when the message is large scroll to the bottom of it
targetPosition = scrollHeight - containerHeight;
}
// Ensure we don't scroll past content boundaries
const maxScroll = scrollHeight - containerHeight;
const finalPosition = Math.min(Math.max(0, targetPosition), maxScroll);
container.scrollTo({
top: finalPosition,
behavior: "smooth",
});
}, []);
// Calculate and set the spacer height based on container dimensions
const updateSpacerHeight = useCallback(() => {
if (!containerRef.current) {
return;
}
const containerHeight = containerRef.current.clientHeight;
// Find the last user message to calculate spacer for
const lastUserIndex = getLastUserMessageIndex();
if (lastUserIndex < 0) {
setSpacerHeight(0);
return;
}
const messageElements = containerRef.current.querySelectorAll(
"[data-message-index]",
) as NodeListOf<HTMLElement>;
if (!messageElements || messageElements.length === 0) {
setSpacerHeight(0);
return;
}
const targetElement = containerRef.current.querySelector(
`[data-message-index="${lastUserIndex}"]`,
) as HTMLElement | null;
if (!targetElement) {
setSpacerHeight(0);
return;
}
const elementsAfter = Array.from(messageElements).filter((el) => {
const idx = Number(el.dataset.messageIndex);
return Number.isFinite(idx) && idx > lastUserIndex;
});
const contentHeightAfterTarget = elementsAfter.reduce(
(sum, el) => sum + el.offsetHeight,
0,
);
// Calculate the spacer height needed to position the user message at the top
// Add extra space for assistant response area
const targetMessageHeight = targetElement.offsetHeight;
// Calculate spacer to position the last user message at the top
// For new messages, we want them to appear at the top regardless of content after
// For large messages, we want to preserve the scroll-to-bottom behavior
// which shows part of the message and space for streaming response
let baseHeight: number;
if (contentHeightAfterTarget === 0) {
// No content after the user message (new message case)
// Position it at the top with some padding
baseHeight = Math.max(0, containerHeight - targetMessageHeight);
} else {
// Content exists after the user message
// Calculate spacer to position user message at top
baseHeight = Math.max(
0,
containerHeight - contentHeightAfterTarget - targetMessageHeight,
);
}
// Only apply spacer height when actively interacting (streaming or pending new message)
// When just viewing a chat, don't add extra space
if (!isActiveInteraction) {
setSpacerHeight(0);
return;
}
// Add extra space for assistant response only when streaming
const extraSpaceForAssistant = isStreaming ? containerHeight * 0.4 : 0;
const calculatedHeight = baseHeight + extraSpaceForAssistant;
setSpacerHeight(calculatedHeight);
}, [getLastUserMessageIndex, isStreaming, isActiveInteraction]);
// Handle new user message submission
const handleNewUserMessage = useCallback(() => {
// Mark that we're expecting a new message and should scroll to it
pendingScrollToUserMessage.current = true;
setIsActiveInteraction(true);
setHasSubmittedMessage(true);
}, []);
// Use layoutEffect to scroll immediately after DOM updates
useLayoutEffect(() => {
if (pendingScrollToUserMessage.current) {
// Find the last user message from current state
const targetUserIndex = getLastUserMessageIndex();
if (targetUserIndex >= 0) {
requestAnimationFrame(() => {
updateSpacerHeight();
requestAnimationFrame(() => {
scrollToMessage(targetUserIndex);
pendingScrollToUserMessage.current = false;
});
});
} else {
pendingScrollToUserMessage.current = false;
// Reset active interaction if no target found
setIsActiveInteraction(isStreaming);
}
}
}, [
messages,
getLastUserMessageIndex,
scrollToMessage,
updateSpacerHeight,
isStreaming,
]);
// Update active interaction state based on streaming and message submission
useEffect(() => {
if (
isStreaming ||
pendingScrollToUserMessage.current ||
hasSubmittedMessage
) {
setIsActiveInteraction(true);
} else {
setIsActiveInteraction(false);
}
}, [isStreaming, hasSubmittedMessage]);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
setIsActiveInteraction(false);
setHasSubmittedMessage(false);
prevChatIdRef.current = chatId;
}
}, [chatId]);
// Recalculate spacer height when messages change
useEffect(() => {
updateSpacerHeight();
}, [messages, updateSpacerHeight]);
// Use ResizeObserver to handle dynamic content changes
useEffect(() => {
if (!containerRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout>;
let immediateUpdate = false;
const resizeObserver = new ResizeObserver((entries) => {
// Check if this is a significant height change (like collapsing content)
let hasSignificantChange = false;
for (const entry of entries) {
const element = entry.target as HTMLElement;
if (
element.dataset.messageIndex &&
entry.contentRect.height !== element.offsetHeight
) {
const heightDiff = Math.abs(
entry.contentRect.height - element.offsetHeight,
);
if (heightDiff > 50) {
hasSignificantChange = true;
break;
}
}
}
// For significant changes, update immediately
if (hasSignificantChange || immediateUpdate) {
updateSpacerHeight();
immediateUpdate = false;
} else {
// For small changes (like streaming text), debounce
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
updateSpacerHeight();
}, 100);
}
});
// Also use MutationObserver for immediate attribute changes
const mutationObserver = new MutationObserver((mutations) => {
// Check if any mutations are related to expanding/collapsing
const hasToggle = mutations.some(
(mutation) =>
mutation.type === "attributes" &&
(mutation.attributeName === "class" ||
mutation.attributeName === "style" ||
mutation.attributeName === "open" ||
mutation.attributeName === "data-expanded"),
);
if (hasToggle) {
immediateUpdate = true;
updateSpacerHeight();
}
});
// Observe the container and all messages
resizeObserver.observe(containerRef.current);
mutationObserver.observe(containerRef.current, {
attributes: true,
subtree: true,
attributeFilter: ["class", "style", "open", "data-expanded"],
});
// Observe all message elements for size changes
const messageElements = containerRef.current.querySelectorAll(
"[data-message-index]",
);
messageElements.forEach((element) => {
resizeObserver.observe(element);
});
return () => {
clearTimeout(resizeTimeout);
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [messages, updateSpacerHeight]);
// Track scroll position
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const handleScroll = () => {
lastScrollTopRef.current = container.scrollTop;
lastScrollHeightRef.current = container.scrollHeight;
};
container.addEventListener("scroll", handleScroll);
// Initialize scroll tracking
lastScrollTopRef.current = container.scrollTop;
lastScrollHeightRef.current = container.scrollHeight;
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
pendingScrollToUserMessage.current = false;
};
}, []);
return useMemo(
() => ({
handleNewUserMessage,
containerRef,
spacerHeight,
}),
[handleNewUserMessage, containerRef, spacerHeight],
);
};
import { useQuery } from "@tanstack/react-query";
import { getModelCapabilities } from "@/api";
import { ModelCapabilitiesResponse } from "@/gotypes";
export function useModelCapabilities(modelName: string | undefined) {
return useQuery<ModelCapabilitiesResponse, Error>({
queryKey: ["modelCapabilities", modelName],
queryFn: () => {
return getModelCapabilities(modelName!);
},
enabled: !!modelName, // Only run query if modelName is provided
gcTime: 60 * 60 * 1000, // Keep in cache for 1 hour
staleTime: 60 * 60 * 1000, // Consider data stale after 1 hour
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
});
}
export function useHasVisionCapability(modelName: string | undefined) {
const { data: capabilitiesResponse } = useModelCapabilities(modelName);
return capabilitiesResponse?.capabilities?.includes("vision") ?? false;
}
import { useQuery } from "@tanstack/react-query";
import { Model } from "@/gotypes";
import { getModels } from "@/api";
import { mergeModels } from "@/utils/mergeModels";
import { useSettings } from "./useSettings";
import { useMemo } from "react";
export function useModels(searchQuery = "") {
const { settings } = useSettings();
const localQuery = useQuery<Model[], Error>({
queryKey: ["models", searchQuery],
queryFn: () => getModels(searchQuery),
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
retry: 10,
// exponential backoff, starting at 100ms and capping at 5s
retryDelay: (attemptIndex) => Math.min(100 * 2 ** attemptIndex, 5000),
refetchOnWindowFocus: true,
refetchInterval: 30 * 1000, // Refetch every 30 seconds to keep models updated
refetchIntervalInBackground: true,
});
const allModels = useMemo(() => {
const models = mergeModels(localQuery.data || [], settings.airplaneMode);
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
const filteredModels = models.filter((model) =>
model.model.toLowerCase().includes(query),
);
const seen = new Set<string>();
return filteredModels.filter((model) => {
const currentModel = model.model.toLowerCase();
if (seen.has(currentModel)) {
return false;
}
seen.add(currentModel);
return true;
});
}
return models;
}, [localQuery.data, searchQuery, settings.airplaneMode]);
return {
...localQuery,
data: allModels,
isLoading: localQuery.isLoading,
};
}
export function useRefetchModels() {
const { refetch } = useModels();
return refetch;
}
import { useCallback, useRef } from "react";
import { useQueryClient, QueryClient } from "@tanstack/react-query";
interface BatcherConfig {
batchInterval?: number; // milliseconds, default 8ms (~120fps)
immediateFirst?: boolean; // if true, first update is immediate
}
export const useQueryBatcher = <T>(
queryKey: readonly unknown[],
config: BatcherConfig = {},
) => {
const queryClient = useQueryClient();
const { batchInterval = 8, immediateFirst = false } = config;
const batchRef = useRef<{
updateBatch: T | undefined;
batchTimeout: number | null;
isFirstUpdate: boolean;
}>({
updateBatch: undefined,
batchTimeout: null,
isFirstUpdate: true,
});
const flushBatch = useCallback(() => {
const { updateBatch, batchTimeout } = batchRef.current;
if (updateBatch) {
queryClient.setQueryData(queryKey, updateBatch);
batchRef.current.updateBatch = undefined;
}
if (batchTimeout) {
clearTimeout(batchTimeout);
batchRef.current.batchTimeout = null;
}
}, [queryClient, queryKey]);
const scheduleBatch = useCallback(
(updater: (old: T | undefined) => T | undefined) => {
const currentData = queryClient.getQueryData<T>(queryKey);
const newBatch = updater(batchRef.current.updateBatch || currentData);
batchRef.current.updateBatch = newBatch;
// If this is the first update and immediateFirst is enabled, apply immediately
if (immediateFirst && batchRef.current.isFirstUpdate) {
batchRef.current.isFirstUpdate = false;
queryClient.setQueryData(queryKey, newBatch);
batchRef.current.updateBatch = undefined;
return;
}
if (batchRef.current.batchTimeout) {
clearTimeout(batchRef.current.batchTimeout);
}
batchRef.current.batchTimeout = setTimeout(flushBatch, batchInterval);
},
[queryClient, queryKey, flushBatch, batchInterval, immediateFirst],
);
const cleanup = useCallback(() => {
if (batchRef.current.batchTimeout) {
clearTimeout(batchRef.current.batchTimeout);
batchRef.current.batchTimeout = null;
}
batchRef.current.updateBatch = undefined;
batchRef.current.isFirstUpdate = true;
}, []);
return {
scheduleBatch,
flushBatch,
cleanup,
};
};
export const createQueryBatcher = <T>(
queryClient: QueryClient,
queryKey: readonly unknown[],
config: BatcherConfig = {},
) => {
const { batchInterval = 8, immediateFirst = false } = config;
let updateBatch: T | undefined = undefined;
let batchTimeout: number | null = null;
let isFirstUpdate = true;
const flushBatch = () => {
if (updateBatch) {
queryClient.setQueryData(queryKey, updateBatch);
updateBatch = undefined;
}
if (batchTimeout) {
clearTimeout(batchTimeout);
batchTimeout = null;
}
};
const scheduleBatch = (updater: (old: T | undefined) => T | undefined) => {
const currentData = queryClient.getQueryData<T>(queryKey);
updateBatch = updater(updateBatch || currentData);
// If this is the first update and immediateFirst is enabled, apply immediately
if (immediateFirst && isFirstUpdate) {
isFirstUpdate = false;
queryClient.setQueryData(queryKey, updateBatch);
updateBatch = undefined;
return;
}
if (batchTimeout) {
clearTimeout(batchTimeout);
}
batchTimeout = setTimeout(flushBatch, batchInterval);
};
const cleanup = () => {
if (batchTimeout) {
clearTimeout(batchTimeout);
batchTimeout = null;
}
updateBatch = undefined;
isFirstUpdate = true;
};
return {
scheduleBatch,
flushBatch,
cleanup,
};
};
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment