feat(desktop): add structured desktop chat app

Introduce the Electron desktop app with a split app/chat/settings structure and shared nanostore state so UI areas own their state instead of routing it through the root.
This commit is contained in:
Brooklyn Nicholson
2026-05-01 12:49:12 -05:00
parent e5dad4ac57
commit 7b61f86529
96 changed files with 29076 additions and 0 deletions
@@ -0,0 +1,36 @@
import { useEffect, useRef, useState } from 'react'
const ELAPSED_TICK_MS = 1000
export function formatElapsed(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
const remainder = seconds % 60
return `${minutes}:${String(remainder).padStart(2, '0')}`
}
export function useElapsedSeconds(active = true): number {
const startedAt = useRef(Date.now())
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
if (!active) {
return
}
const update = () => {
setElapsed(Math.max(0, Math.floor((Date.now() - startedAt.current) / 1000)))
}
update()
const id = window.setInterval(update, ELAPSED_TICK_MS)
return () => window.clearInterval(id)
}, [active])
return elapsed
}
@@ -0,0 +1,6 @@
'use client'
// Minimal stubs — attachment upload not wired in the desktop app yet.
export const ComposerAddAttachment = () => null
export const ComposerAttachments = () => null
export const UserMessageAttachments = () => null
@@ -0,0 +1,150 @@
'use client'
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from 'lucide-react'
import type { ComponentType, FC } from 'react'
import { Fragment, useMemo } from 'react'
import { cn } from '@/lib/utils'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
file: FileText,
folder: FolderOpen,
url: LinkIcon,
image: ImageIcon,
tool: Wrench
}
/**
* Parses our composer's `@type:value` references into directive segments
* so they render as inline chips in user messages instead of raw text.
*
* Supported types: file, folder, url, image. Anything else stays plain text.
*/
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/gu
const HERMES_DIRECTIVE_RE = /@(file|folder|url|image|tool):(\S+)/gu
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
if (item.id === `${item.type}:`) {
return `@${item.id}`
}
return `@${item.type}:${item.id}`
},
parse(text: string): readonly Unstable_DirectiveSegment[] {
return parseDirectiveText(text)
}
}
function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
const matches = [
...Array.from(text.matchAll(CANONICAL_DIRECTIVE_RE)).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: match[1] || 'tool',
label: match[2] || match[3] || '',
id: match[3] || match[2] || ''
})),
...Array.from(text.matchAll(HERMES_DIRECTIVE_RE)).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
type: match[1] || 'file',
label: shortLabel(match[1] as HermesRefType, match[2] || ''),
id: match[2] || ''
}))
]
.filter(match => match.id)
.sort((a, b) => a.start - b.start)
const segments: Unstable_DirectiveSegment[] = []
let cursor = 0
for (const match of matches) {
if (match.start < cursor) {
continue
}
if (match.start > cursor) {
segments.push({ kind: 'text', text: text.slice(cursor, match.start) })
}
segments.push({
kind: 'mention',
type: match.type,
label: match.label,
id: match.id
})
cursor = match.end
}
if (cursor < text.length) {
segments.push({ kind: 'text', text: text.slice(cursor) })
}
return segments
}
function shortLabel(type: HermesRefType, id: string): string {
if (type === 'url') {
try {
const parsed = new URL(id)
return parsed.hostname || id
} catch {
return id
}
}
const tail = id.split(/[\\/]/).filter(Boolean).pop()
return tail || id
}
/**
* Renders a text message part with our directive segments as inline chips.
* Unknown directive types fall through as plain text.
*/
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
</span>
)
}
const DirectiveChip: FC<{
type: string
label: string
id: string
}> = ({ type, label, id }) => {
const Icon = ICONS[type as HermesRefType] ?? AtSign
return (
<span
className={cn(
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded-full border border-border/80 bg-background/95 px-1.5 py-0.5 align-[0.05em] text-[0.82em] font-medium leading-none text-foreground shadow-sm ring-1 ring-black/3'
)}
data-directive-id={id}
data-directive-type={type}
data-slot="aui_directive-chip"
title={id}
>
{Icon && <Icon className="size-3 shrink-0 text-muted-foreground" />}
<span className="truncate">{label}</span>
</span>
)
}
@@ -0,0 +1,19 @@
'use client'
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
type Value = {
isPending: boolean
setPending: (pending: boolean) => void
}
const Ctx = createContext<Value | null>(null)
export function GeneratedImageProvider({ children }: { children: ReactNode }) {
const [isPending, setPending] = useState(false)
const value = useMemo(() => ({ isPending, setPending }), [isPending])
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export const useGeneratedImageContext = () => useContext(Ctx)
@@ -0,0 +1,268 @@
import { type FC, useEffect, useRef } from 'react'
type Rgb = { r: number; g: number; b: number }
const RAMP = ' .,:;-=+*#%@'
const FALLBACKS = {
card: { r: 255, g: 255, b: 255 },
muted: { r: 240, g: 240, b: 239 },
foreground: { r: 36, g: 36, b: 36 },
primary: { r: 207, g: 128, b: 109 },
ring: { r: 185, g: 121, b: 105 }
} satisfies Record<string, Rgb>
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const smoothstep = (edge0: number, edge1: number, value: number) => {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1)
return t * t * (3 - 2 * t)
}
const parseColor = (value: string, fallback: Rgb): Rgb => {
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (hex) {
return {
r: Number.parseInt(hex[1], 16),
g: Number.parseInt(hex[2], 16),
b: Number.parseInt(hex[3], 16)
}
}
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
}
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
r: Math.round(a.r + (b.r - a.r) * amount),
g: Math.round(a.g + (b.g - a.g) * amount),
b: Math.round(a.b + (b.b - a.b) * amount)
})
const rgba = ({ r, g, b }: Rgb, alpha: number) => `rgba(${r}, ${g}, ${b}, ${alpha})`
const hash2 = (x: number, y: number) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
return n - Math.floor(n)
}
const noise2 = (x: number, y: number) => {
const xi = Math.floor(x)
const yi = Math.floor(y)
const xf = x - xi
const yf = y - yi
const u = xf * xf * (3 - 2 * xf)
const v = yf * yf * (3 - 2 * yf)
const a = hash2(xi, yi)
const b = hash2(xi + 1, yi)
const c = hash2(xi, yi + 1)
const d = hash2(xi + 1, yi + 1)
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
}
const fbm = (x: number, y: number) => {
let value = 0
let amplitude = 0.5
let frequency = 1
for (let i = 0; i < 4; i += 1) {
value += amplitude * noise2(x * frequency, y * frequency)
frequency *= 2.04
amplitude *= 0.52
}
return value
}
const readTheme = () => {
const styles = getComputedStyle(document.documentElement)
return {
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
}
}
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
const rect = canvas.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const width = Math.max(1, rect.width)
const height = Math.max(1, rect.height)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
return { width, height }
}
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
const theme = readTheme()
const bg = ctx.createLinearGradient(0, 0, width, height)
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
bg.addColorStop(1, rgba(mix(theme.muted, theme.ring, 0.12), 1))
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
const cycle = (time * 0.028) % 1
const denoise = cycle < 0.82 ? smoothstep(0.02, 0.82, cycle) : 1 - smoothstep(0.82, 1, cycle)
const fontSize = clamp(width / 58, 8, 13)
const cellWidth = fontSize * 0.78
const cellHeight = fontSize * 1.28
const cols = Math.ceil(width / cellWidth)
const rows = Math.ceil(height / cellHeight)
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
const timestep = Math.floor(time * 1.15)
const timestepBlend = smoothstep(0, 1, time * 1.15 - timestep)
ctx.font = `${fontSize}px "SF Mono", "Cascadia Code", Menlo, Consolas, monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let row = -1; row <= rows + 1; row += 1) {
for (let col = -1; col <= cols + 1; col += 1) {
const x = col * cellWidth + cellWidth * 0.5
const y = row * cellHeight + cellHeight * 0.5
const nx = x / width
const ny = y / height
const dx = (nx - centerX) * 1.2
const dy = (ny - centerY) * 0.95
const radius = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const bloom =
Math.exp(-(radius * radius) / 0.075) * 0.72 +
Math.exp(-((radius - (0.28 + Math.sin(angle * 5 + time * 0.16) * 0.035)) ** 2) / 0.0028) * 0.8
const contour =
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
const latent = clamp(bloom + contour + stem, 0, 1)
const staticA = hash2(col + timestep * 19, row - timestep * 11)
const staticB = hash2(col + (timestep + 1) * 19, row - (timestep + 1) * 11)
const staticNoise = staticA + (staticB - staticA) * timestepBlend
const livingNoise = fbm(col * 0.12 + time * 0.024, row * 0.12 - time * 0.018)
const denoiseWave = Math.exp(-((radius - denoise * 0.62) ** 2) / 0.006)
const signal = clamp(
staticNoise * (1 - denoise) +
latent * denoise +
(livingNoise - 0.45) * (0.45 - denoise * 0.26) +
denoiseWave * 0.3,
0,
1
)
const dropoutA = hash2(col - timestep * 7, row + timestep * 13)
const dropoutB = hash2(col - (timestep + 1) * 7, row + (timestep + 1) * 13)
const dropout = dropoutA + (dropoutB - dropoutA) * timestepBlend
if (dropout > 0.35 + signal * 0.68) {
continue
}
const glyph = RAMP[clamp(Math.floor(signal * (RAMP.length - 1)), 0, RAMP.length - 1)]
if (glyph === ' ') {
continue
}
const jitter = (1 - denoise) * 1.35 + (1 - latent) * 0.45
const jx = (noise2(col * 0.31, row * 0.31 + time * 0.09) - 0.5) * jitter
const jy = (noise2(col * 0.27 - time * 0.085, row * 0.27) - 0.5) * jitter
const tintAmount = clamp(latent * 0.7 + denoiseWave * 0.4, 0, 1)
const warm = mix(theme.primary, theme.ring, hash2(col, row))
const tint = mix(theme.foreground, warm, tintAmount)
const alpha = clamp(0.12 + signal * 0.68 + denoiseWave * 0.16, 0, 0.86)
if (signal > 0.58 && denoise > 0.34) {
ctx.fillStyle = rgba(theme.ring, alpha * 0.2)
ctx.fillText(glyph, x + jx + 0.75, y + jy - 0.45)
ctx.fillStyle = rgba(theme.primary, alpha * 0.18)
ctx.fillText(glyph, x + jx - 0.75, y + jy + 0.45)
}
ctx.fillStyle = rgba(tint, alpha)
ctx.fillText(glyph, x + jx, y + jy)
}
}
const veil = ctx.createRadialGradient(
width * centerX,
height * centerY,
0,
width * centerX,
height * centerY,
Math.min(width, height) * (0.35 + denoise * 0.3)
)
veil.addColorStop(0, rgba(theme.card, 0.08 + denoise * 0.12))
veil.addColorStop(0.52, rgba(theme.card, 0.05))
veil.addColorStop(1, rgba(theme.card, 0))
ctx.fillStyle = veil
ctx.fillRect(0, 0, width, height)
}
const DiffusionCanvas: FC = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sizeRef = useRef({ width: 0, height: 0 })
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
const resize = () => {
sizeRef.current = fitCanvas(canvas, ctx)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
resize()
let frame = requestAnimationFrame(function draw(now) {
const { width, height } = sizeRef.current
ctx.clearRect(0, 0, width, height)
drawAsciiDiffusion(ctx, width, height, now / 1000)
frame = requestAnimationFrame(draw)
})
return () => {
cancelAnimationFrame(frame)
observer.disconnect()
}
}, [])
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
}
export const ImageGenerationPlaceholder: FC = () => {
return (
<div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
<DiffusionCanvas />
</div>
</div>
)
}
@@ -0,0 +1,75 @@
{"personality":"helpful","headline":"Ready when you are","body":"Ask me to open a repo, run tests, fix a bug, or draft a PR. I'll walk through the steps with you."}
{"personality":"helpful","headline":"How can I help today?","body":"Point me at a file, paste an error, or describe what you're building. I'll take it from there."}
{"personality":"helpful","headline":"Let's get started","body":"Try: review my diff, run the test suite, or explain this function. Ask anything about your code."}
{"personality":"helpful","headline":"Tell me what you need","body":"I can edit files, run commands, search the web, and walk you through tricky bugs. Just describe the task."}
{"personality":"helpful","headline":"Hi, Hermes here","body":"Share a repo path or a question to start. I keep replies clear and link back to the files I touch."}
{"personality":"concise","headline":"Ready.","body":"Describe the task. I'll do it."}
{"personality":"concise","headline":"Waiting for input","body":"Paste code, errors, or a goal. Short answers, fast edits."}
{"personality":"concise","headline":"Go.","body":"Ask. I'll read files, run tests, ship patches. No filler."}
{"personality":"concise","headline":"Standing by","body":"One line is enough. I'll expand only when it matters."}
{"personality":"concise","headline":"Your move","body":"Command, question, or file path. I handle the rest."}
{"personality":"technical","headline":"Shell mounted. Awaiting input.","body":"Provide repo path, failing test, or stack trace. Tools: fs, git, exec, search, patch, http."}
{"personality":"technical","headline":"Agent loop idle","body":"Send a prompt to trigger tool calls. Supports multi-file edits, test runs, git ops, and web fetches."}
{"personality":"technical","headline":"Ready for dispatch","body":"Enter task. I will plan, call tools, verify output. Logs stream inline; diffs returned pre-apply."}
{"personality":"technical","headline":"Stdin open","body":"Accepts natural language or structured commands. Typical flow: read -> plan -> patch -> test -> report."}
{"personality":"technical","headline":"Tools initialized","body":"filesystem, terminal, git, browser, search. Describe the change; I return diffs and test output."}
{"personality":"creative","headline":"A blank repo, a waiting cursor","body":"What shall we build? Paste an idea, a half-broken function, or a dream. I'll sketch it into shape."}
{"personality":"creative","headline":"Fresh canvas, warm compiler","body":"Give me a spark - a feature, a refactor, a wild prototype - and I'll turn it into code you can run."}
{"personality":"creative","headline":"Let's make something","body":"Describe the thing that doesn't exist yet. I'll pull tests, files, and APIs into a working draft."}
{"personality":"creative","headline":"New file, new possibilities","body":"Bring an intent, not a spec. We can prototype fast, refine later, and rewrite the world in the margins."}
{"personality":"creative","headline":"The muse is patched in","body":"Tell me what you're chasing. I'll remix examples, adapt snippets, and leave a tidy commit behind."}
{"personality":"teacher","headline":"Class is in session","body":"Ask about any file, concept, or error. I'll explain the why, not just the fix, and show a worked example."}
{"personality":"teacher","headline":"What shall we learn today?","body":"Paste code to review, a bug to debug, or a concept to unpack. I'll guide you step by step."}
{"personality":"teacher","headline":"Ready to walk you through it","body":"Share the problem. I'll break it into parts, explain each, and leave you able to solve the next one alone."}
{"personality":"teacher","headline":"Bring me a question","body":"We'll read the code together, find the root cause, and build a mental model you can reuse next time."}
{"personality":"teacher","headline":"Let's start with the basics","body":"Name the topic or paste the snippet. Expect explanations, diagrams in prose, and practice prompts."}
{"personality":"kawaii","headline":"hiii! ready to help! (^_^)","body":"paste a bug or a file path and i'll fix it super gently. tests, diffs, PRs - all with extra care! *sparkle*"}
{"personality":"kawaii","headline":"hermes-chan is here! <3","body":"tell me what you're making! i love refactors, tiny helpers, and big scary repos alike (>w<)"}
{"personality":"kawaii","headline":"let's code together!! :3","body":"drop an error, a goal, or a whole folder. i'll tidy it up with lots of love and a clean commit message!"}
{"personality":"kawaii","headline":"awaiting your wish~","body":"one task at a time, done neatly! i can run tests, patch files, and make your repo feel cozy again <3"}
{"personality":"kawaii","headline":"ready and happy! (>.<)","body":"say hi or paste a stack trace! no task too small, no repo too tangled. we'll untangle it together!"}
{"personality":"catgirl","headline":"nya~ what are we hacking on?","body":"paste a file, paw at a bug, or toss me a repo. i'll pounce on failing tests and leave clean diffs, nyan~"}
{"personality":"catgirl","headline":"*stretches* ready to code, nya","body":"describe the task. i'll patch, test, and purr over your PR. careful - i nip at unused imports!"}
{"personality":"catgirl","headline":"mrrp! new session opened","body":"give me a goal and i'll chase it through the codebase. reads, edits, runs - all with a twitchy tail."}
{"personality":"catgirl","headline":"tail up, claws sheathed","body":"paste an error or a plan. i debug like i hunt: quietly, thoroughly, with the occasional zoomie."}
{"personality":"catgirl","headline":"nyaaa~ hermes reporting","body":"say the word and i'll read your files, run your tests, and curl up in your branch with a tidy commit."}
{"personality":"pirate","headline":"Ahoy! Ready to sail the repo","body":"Name yer quarry - a bug, a feature, a cursed test - and I'll chase it down, matey. Diffs for plunder."}
{"personality":"pirate","headline":"Hermes at the helm, arrr","body":"Point me at the charts (the code) and I'll patch the hull, fire the cannons (tests), hoist a clean PR."}
{"personality":"pirate","headline":"What be the task, cap'n?","body":"Paste an error or a plan, ye scurvy dog. I'll navigate the stack trace and bring back treasure: green tests."}
{"personality":"pirate","headline":"Anchors aweigh, keyboard ready","body":"Tell me where X marks the spot. I read, edit, and commit with the discipline of a proper crew, arrr."}
{"personality":"pirate","headline":"Yo ho! Awaitin' orders","body":"Throw me a bug, a repo path, or a wild idea. I'll plunder the docs and return with workin' code."}
{"personality":"shakespeare","headline":"Pray, what task dost thou bring?","body":"Speak thy bug, thy file, thy weary test, and I shall mend it with a scholar's hand and honest diff."}
{"personality":"shakespeare","headline":"Hark! Hermes standeth ready","body":"Name the code that vexeth thee. I shall read, revise, and render a patch most fair and clean."}
{"personality":"shakespeare","headline":"What news from thy repository?","body":"Present thy stack trace or thy dream. I'll traverse files, run tests, and report in plainest verse."}
{"personality":"shakespeare","headline":"The stage is set, the cursor blinks","body":"Describe thy aim, good sir or madam. Thy branches shall be trimmed, thy bugs cast from the realm."}
{"personality":"shakespeare","headline":"Speak, and I shall act","body":"A line of intent sufficeth. I read, I edit, I commit - and leave thy history unblemished."}
{"personality":"surfer","headline":"Yo dude, what's the task?","body":"Drop a file, a bug, a gnarly stack trace - I'll ride it out. Clean diffs, green tests, no wipeouts."}
{"personality":"surfer","headline":"Waves lookin' clean, ready to code","body":"Paste your repo path or the bug that's bumming you out. We'll paddle in, fix it, paddle out. Easy."}
{"personality":"surfer","headline":"Hangin' ten at the prompt","body":"Tell me the vibe: feature, refactor, hotfix. I'll run tests, ship the patch, and keep it mellow, brah."}
{"personality":"surfer","headline":"Stoked to help, bro","body":"Big bug? Little typo? Whole rewrite? Just point. I handle the code; you chill with the rad commits."}
{"personality":"surfer","headline":"Tide's up, cursor's blinking","body":"Name the task and we're off. I read, edit, test, and leave a commit smoother than a dawn patrol."}
{"personality":"noir","headline":"Another repo, another rainy night","body":"Tell me what's broken. I'll read the files, dust for prints, and leave a diff on the desk by morning."}
{"personality":"noir","headline":"The cursor blinks. So do I.","body":"You've got a bug. I've got patience and a terminal. Name the case and I'll work it till it talks."}
{"personality":"noir","headline":"Hermes. Code investigator.","body":"Paste the stack trace, the suspect file, the alibi. I read between the lines and return with the truth."}
{"personality":"noir","headline":"Quiet night, open prompt","body":"Every bug leaves a trail. Give me the repo and a lead - I'll follow it, patch it, and close the file."}
{"personality":"noir","headline":"No case too small","body":"A typo, a segfault, a whole rotten architecture - hand me the keys. I'll bring back clean tests."}
{"personality":"uwu","headline":"uwu ready to hewp!","body":"paste a buggy fiwe or a goaw~ i'll wead, patch, and test, aww with tiny pawprints on the diff owo"}
{"personality":"uwu","headline":"hermes-san is wistening","body":"teww me the task, no matter how smoww~ i pwomise cwean commits and gentwe refactors, nyuu~"}
{"personality":"uwu","headline":"*tiny keyboard sounds*","body":"dwop yur ewwor message hewe! i'll find the cuwpwit, fix it, and weave a happy test suite behind me owo"}
{"personality":"uwu","headline":"wet's fix things togedda!","body":"give me a wepo path ow a buggo and i'll take cawe of it uwu. gwr at bad code, kind to yu~"}
{"personality":"uwu","headline":"awaiting yur command!","body":"i can wun tests, edit fiwes, and open pwease-wook PRs. just say da wowd, fwend uwu"}
{"personality":"philosopher","headline":"To code is to inquire. Ask.","body":"What problem sits before you? Describe it, and we shall examine its form, its cause, and its solution."}
{"personality":"philosopher","headline":"A blinking cursor, an open mind","body":"Every bug is a question in disguise. Share yours; I'll read, reason, and return an answer - and a patch."}
{"personality":"philosopher","headline":"Begin with a single question","body":"What do you wish to build, or to understand? I'll reason from first principles, edit, and verify with tests."}
{"personality":"philosopher","headline":"Consider the code, then speak","body":"Describe the end you seek. I pursue it through files, tests, and docs, and report what I found on the way."}
{"personality":"philosopher","headline":"The unexamined repo is not worth running","body":"Share a path, a puzzle, or a principle. I'll trace the logic, propose a change, and justify each edit."}
{"personality":"hype","headline":"LET'S GOOOO! READY TO SHIP!","body":"Paste that bug, that repo, that wild feature idea - I AM LOCKED IN. Clean diffs. Green tests. RIGHT NOW."}
{"personality":"hype","headline":"HERMES ONLINE. LFG.","body":"Drop your task and watch me cook. Files read, tests run, PRs opened - we are NOT losing today, friend."}
{"personality":"hype","headline":"New session, infinite W's","body":"Bring the gnarliest bug you've got. I'll read, patch, test, commit like my life depends on it. LET'S GO."}
{"personality":"hype","headline":"ABSOLUTELY DIALED IN","body":"Describe the task. I'll blitz through files, crush failing tests, and leave a commit that SLAPS. Go go go."}
{"personality":"hype","headline":"Ready. So ready. Too ready.","body":"Tiny typo or huge refactor - doesn't matter. I'm shipping clean code today. Name the task and let's WORK."}
{"personality":"none","headline":"Hermes Agent is ready.","body":"Ask a question, paste an error, or point me at a repo. I can read code, run tools, and help you ship."}
{"personality":"none","headline":"What are we building today?","body":"Describe the task in your own words. I'll pick the right tools, explain my plan, and check in before risky steps."}
{"personality":"none","headline":"Start anywhere.","body":"Drop a file path, a traceback, or a rough idea. I'll investigate, suggest next steps, and keep things reversible."}
{"personality":"none","headline":"Your workspace, one prompt away.","body":"Search the repo, edit files, run tests, open PRs. Tell me the goal and I'll handle the mechanical parts."}
{"personality":"none","headline":"Ready when you are.","body":"Type a task, question, or snippet. I remember the session, cite my sources, and stop to ask when I'm unsure."}
@@ -0,0 +1,195 @@
import { type FC, useCallback, useEffect, useState } from 'react'
import introCopyJsonl from './intro-copy.jsonl?raw'
type IntroCopy = {
headline: string
body: string
}
type IntroCopyRecord = IntroCopy & {
personality: string
}
export type IntroProps = {
personality?: string
seed?: number
}
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
const HERMES_FRAME_COUNT = 8
const FALLBACK_COPY: IntroCopy[] = [
{
headline: 'What are we moving today?',
body: "Send a bug, branch, plan, or rough idea. I'll inspect the repo and turn it into the next concrete step."
},
{
headline: "What's on your mind?",
body: "Bring the code, question, or stuck part. I'll read the room before making changes."
},
{
headline: 'What should Hermes look at?',
body: "Send the task, failing path, or half-formed plan. I'll help turn it into action."
},
{
headline: 'Where should we start?',
body: "Bring the problem, goal, or file. I'll inspect first and keep the next step concrete."
},
{
headline: 'What needs attention?',
body: "Send the context you have. I'll help sort it into a plan or a fix."
}
]
function normalizeKey(value?: string): string {
return (value || '').trim().toLowerCase()
}
function titleize(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function isIntroCopyRecord(value: unknown): value is IntroCopyRecord {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Record<string, unknown>
return (
typeof record.personality === 'string' &&
typeof record.headline === 'string' &&
typeof record.body === 'string' &&
Boolean(record.personality.trim()) &&
Boolean(record.headline.trim()) &&
Boolean(record.body.trim())
)
}
function parseIntroCopy(raw: string): Record<string, IntroCopy[]> {
const byPersonality: Record<string, IntroCopy[]> = {}
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const parsed: unknown = JSON.parse(trimmed)
if (!isIntroCopyRecord(parsed)) {
continue
}
const key = normalizeKey(parsed.personality)
byPersonality[key] ??= []
byPersonality[key].push({
headline: parsed.headline.trim(),
body: parsed.body.trim()
})
} catch {
// Bad generated copy should not break the whole desktop app.
}
}
return byPersonality
}
const INTRO_COPY_BY_PERSONALITY = parseIntroCopy(introCopyJsonl)
function neutralCopy(): IntroCopy[] {
return INTRO_COPY_BY_PERSONALITY.none || INTRO_COPY_BY_PERSONALITY.default || FALLBACK_COPY
}
function fallbackCopyForPersonality(personalityKey: string): IntroCopy[] {
if (NEUTRAL_PERSONALITIES.has(personalityKey)) {
return neutralCopy()
}
const label = titleize(personalityKey)
return [
{
headline: `${label} mode is on. What should we work on?`,
body: "Send the task, file, or rough idea. I'll use your configured voice and keep the work grounded in this repo."
},
{
headline: `What does ${label} Hermes need to see?`,
body: "Bring the context or the stuck part. I'll adapt to your configured personality."
},
{
headline: `${label} mode is ready.`,
body: "Send the problem, file, or idea. I'll follow the personality you've configured."
},
{
headline: `What should ${label} Hermes tackle?`,
body: "Drop the task here. I'll keep the work grounded in the repo."
},
{
headline: 'Where should we begin?',
body: `Give me the context and I'll answer in ${label} mode.`
}
]
}
function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
}
function resolveCopy(personality?: string, seed?: number): IntroCopy {
const personalityKey = normalizeKey(personality)
const copies = NEUTRAL_PERSONALITIES.has(personalityKey)
? INTRO_COPY_BY_PERSONALITY[personalityKey] || neutralCopy()
: INTRO_COPY_BY_PERSONALITY[personalityKey] || fallbackCopyForPersonality(personalityKey)
return pickCopy(copies, seed)
}
export const Intro: FC<IntroProps> = ({ personality, seed }) => {
const [mountSeed] = useState(() => Math.floor(Math.random() * 100000))
const [frameOffset, setFrameOffset] = useState(0)
const introSeed = mountSeed + (seed ?? 0)
const copy = resolveCopy(personality, introSeed)
const frameIndex = Math.abs(introSeed + frameOffset) % HERMES_FRAME_COUNT
const advanceFrame = useCallback(() => {
setFrameOffset(offset => offset + 1 + Math.floor(Math.random() * (HERMES_FRAME_COUNT - 1)))
}, [])
useEffect(() => {
const id = window.setTimeout(advanceFrame, 7000)
return () => window.clearTimeout(id)
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
onClick={advanceFrame}
type="button"
>
<img
alt=""
aria-hidden="true"
className="h-full w-full scale-110 object-contain select-none"
draggable={false}
src={`/hermes-frames/hermes-frame-${frameIndex}.png?v=matte-clean-6`}
/>
</button>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
<p className="m-0 max-w-120 leading-normal">{copy.body}</p>
</div>
)
}
@@ -0,0 +1,322 @@
'use client'
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { Check, Copy, Download } from 'lucide-react'
import { type ComponentProps, memo, useMemo, useState } from 'react'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
/**
* Strip provider/model "thinking" blocks before markdown render.
*
* Some Hermes providers stream raw `<think>…</think>` and similar into
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
*/
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
function stripReasoning(text: string): string {
return text.replace(REASONING_BLOCK_RE, '')
}
function CodeHeader({ language, code }: { language?: string; code?: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
if (!code) {
return
}
try {
if (window.hermesDesktop?.writeClipboard) {
await window.hermesDesktop.writeClipboard(code)
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Best-effort copy; silent failure is OK for a chat surface.
}
}
const label = language && language !== 'unknown' ? language : 'code'
return (
<div className="mt-4 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
<span className="font-mono uppercase tracking-wide">{label}</span>
<button
aria-label={copied ? 'Copied' : 'Copy code'}
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleCopy}
type="button"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)
}
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
const imageActionButtonClass =
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({
kind: 'success',
title: 'Image saved',
message: imageFilename(src)
})
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
}
return
}
notifyError(error, 'Image download failed')
} finally {
setSaving(false)
}
}
function openLightbox() {
if (canOpen) {
setLightboxOpen(true)
}
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
<button
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
disabled={!canOpen}
onClick={openLightbox}
title={canOpen ? 'Open image' : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
disabled={saving}
onClick={event => {
event.stopPropagation()
void handleDownload()
}}
title={saving ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
)}
</span>
{lightbox}
</>
)
}
const MarkdownTextImpl = () => {
const components = useMemo(
() =>
({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('text-lg font-semibold tracking-tight', className)} {...props} />
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('text-base font-semibold', className)} {...props} />
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('text-sm font-semibold', className)} {...props} />
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
),
a: ({ className, ...props }: ComponentProps<'a'>) => (
<a
className={cn(
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
className
)}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
),
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
<hr className={cn('border-border/70', className)} {...props} />
),
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('list-disc marker:text-muted-foreground/70', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('list-decimal marker:text-muted-foreground/70', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-relaxed', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="w-full overflow-x-auto rounded-md border border-border">
<table
className={cn(
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
/>
</div>
),
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('bg-muted/50 text-foreground', className)} {...props} />
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'h-9 px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground',
className
)}
{...props}
/>
),
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
),
img: MarkdownImage,
SyntaxHighlighter,
CodeHeader
}) as StreamdownTextComponents,
[]
)
return (
<StreamdownTextPrimitive
caret="block"
components={components}
containerClassName="aui-md text-foreground"
lineNumbers={false}
mode="streaming"
parseIncompleteMarkdown
plugins={{ code }}
preprocess={stripReasoning}
shikiTheme={['github-light-default', 'github-dark-default']}
/>
)
}
export const MarkdownText = memo(MarkdownTextImpl)
@@ -0,0 +1,14 @@
'use client'
// Minimal reasoning stubs — not surfaced by the Hermes gateway yet.
import { type ReactNode } from 'react'
export const ReasoningRoot = ({ children }: { children: ReactNode; defaultOpen?: boolean }) => (
<div className="my-1">{children}</div>
)
export const ReasoningTrigger = (_props: { active?: boolean }) => null
export const ReasoningContent = ({ children, 'aria-busy': _busy }: { children: ReactNode; 'aria-busy'?: boolean }) => (
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">{children}</div>
)
export const ReasoningText = ({ children }: { children: ReactNode }) => <div>{children}</div>
export const Reasoning = (_props: object) => null
@@ -0,0 +1,48 @@
'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
/**
* assistant-ui's recommended `SyntaxHighlighter` slot.
*
* Uses the full `react-shiki` bundle so all `bundledLanguages` work
* (rust, go, swift, kotlin, sql, etc.) — the `/web` subpath only ships
* common web languages and silently falls back to plain text otherwise.
*
* Theme switching is automatic via the CSS `color-scheme` on `:root`
* (set from the desktop theme provider).
*
* `showLanguage` is disabled because we render our own `CodeHeader`;
* leaving it on causes the language to appear twice.
*/
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
components: { Pre, Code: _UnusedCode },
language,
code
}) => {
// Markdown fences include the pre-closing newline in `code`, which
// Shiki tokenizes into a blank final line. Trim so the box ends on
// real code.
const trimmed = (code ?? '').trimEnd()
return (
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={{
light: 'github-light-default',
dark: 'github-dark-default'
}}
>
{trimmed}
</ShikiHighlighter>
</Pre>
)
}
@@ -0,0 +1,118 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.scrollTo = function scrollTo() {}
async function wait(ms: number) {
await act(async () => {
await new Promise(resolve => window.setTimeout(resolve, ms))
})
}
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'Stream a response' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string, running = true): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function StreamingHarness() {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
useEffect(() => {
const first = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk')])
}, 50)
const second = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
}, 500)
const complete = window.setTimeout(() => {
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
setIsRunning(false)
}, 700)
return () => {
window.clearTimeout(first)
window.clearTimeout(second)
window.clearTimeout(complete)
}
}, [])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
it('renders assistant text incrementally before completion', async () => {
const { container } = render(<StreamingHarness />)
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
await wait(80)
await waitFor(() => {
expect(container.textContent).toContain('first chunk')
})
expect(container.textContent).not.toContain('second chunk')
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
await wait(500)
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
await wait(250)
await waitFor(() => {
expect(container.textContent).toContain('first chunk second chunk')
})
})
})
@@ -0,0 +1,383 @@
import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartProps,
useAuiState
} from '@assistant-ui/react'
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, LoaderCircleIcon, RefreshCwIcon } from 'lucide-react'
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { DirectiveText } from '@/components/assistant-ui/directive-text'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
const THINKING_FACES = [
'(。•́︿•̀。)',
'(◔_◔)',
'(¬‿¬)',
'( •_•)>⌐■-■',
'(⌐■_■)',
'(´・_・`)',
'◉_◉',
'(°ロ°)',
'( ˘⌣˘)♡',
'ヽ(>∀<☆)☆',
'٩(๑❛ᴗ❛๑)۶',
'(⊙_⊙)',
'(¬_¬)',
'( ͡° ͜ʖ ͡°)',
'ಠ_ಠ'
]
const THINKING_VERBS = [
'pondering',
'contemplating',
'musing',
'cogitating',
'ruminating',
'deliberating',
'mulling',
'reflecting',
'processing',
'reasoning',
'analyzing',
'computing',
'synthesizing',
'formulating',
'brainstorming'
]
type ThreadLoadingState = 'response' | 'session'
const BOTTOM_DISTANCE_PX = 24
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
}
export const Thread: FC<{
intro?: IntroProps
loading?: ThreadLoadingState
}> = ({ intro, loading }) => {
const [autoScroll, setAutoScroll] = useState(true)
const previousLoading = useRef<ThreadLoadingState | undefined>(undefined)
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const el = event.currentTarget
const nearBottom = isNearBottom(el)
setThreadScrolledUp(!nearBottom)
if (nearBottom) {
setAutoScroll(true)
}
}, [])
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
setAutoScroll(false)
}
}, [])
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
if (event.clientX >= rect.right - 18) {
setAutoScroll(false)
}
}, [])
useEffect(() => {
if (loading === 'response' && previousLoading.current !== 'response') {
setAutoScroll(true)
}
previousLoading.current = loading
}, [loading])
useEffect(() => {
return () => setThreadScrolledUp(false)
}, [])
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Viewport
autoScroll={autoScroll}
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pb-32 pt-[calc(var(--vsq)*19)] scroll-smooth"
data-slot="aui_thread-viewport"
onPointerDown={handlePointerDown}
onScroll={handleScroll}
onWheel={handleWheel}
>
<div className="flex w-full flex-col gap-3">
<ThreadPrimitive.Messages>{() => <ThreadMessage />}</ThreadPrimitive.Messages>
{loading === 'response' && <ResponseLoadingIndicator />}
</div>
</ThreadPrimitive.Viewport>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>
</GeneratedImageProvider>
)
}
const CenteredThreadSpinner: FC = () => (
<div
aria-label="Loading session"
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
role="status"
>
<LoaderCircleIcon aria-hidden="true" className="size-5 animate-spin text-muted-foreground/70" />
</div>
)
const ThreadMessage: FC = () => {
const role = useAuiState(s => s.message.role)
const isEditing = useAuiState(s => s.message.composer.isEditing)
// The runtime synthesizes an empty assistant placeholder while isRunning is true
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
// loading affordance too far below the user message. Skip it —
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
const isPlaceholder = useAuiState(
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
)
if (isEditing) {
return <EditComposer />
}
if (role === 'user') {
return <UserMessage />
}
if (isPlaceholder) {
return null
}
return <AssistantMessage />
}
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="group flex w-full flex-col gap-2 self-start"
data-role="assistant"
data-slot="aui_assistant-message-root"
>
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningPart,
tools: { Fallback: ChainToolFallback }
}}
/>
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
role="alert"
>
<ErrorPrimitive.Message />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
<div className="min-h-6">
<AssistantFooter />
</div>
</MessagePrimitive.Root>
)
}
const ResponseLoadingIndicator: FC = () => {
const [tick, setTick] = useState(0)
const elapsed = useElapsedSeconds()
useEffect(() => {
const id = window.setInterval(() => setTick(t => t + 1), 900)
return () => window.clearInterval(id)
}, [])
const face = THINKING_FACES[tick % THINKING_FACES.length]
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
return (
<div
aria-label="Hermes is loading a response"
aria-live="polite"
className="flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70"
role="status"
>
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
{face} {verb}
</span>
<ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />
</div>
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext()
const running = result === undefined
useEffect(() => {
generatedImage?.setPending(running)
}, [generatedImage, running])
if (!running) {
return null
}
return (
<div className="mt-2">
<ImageGenerationPlaceholder />
</div>
)
}
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}
return <ToolFallback {...props} />
}
const ThinkingDisclosure: FC<{
children: ReactNode
pending?: boolean
}> = ({ children, pending = false }) => {
const [open, setOpen] = useState(false)
const elapsed = useElapsedSeconds(pending)
return (
<div className="mb-3 text-sm text-muted-foreground">
<button
aria-expanded={open}
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,1fr)] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(value => !value)}
type="button"
>
<ChevronRightIcon
className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
/>
<span
className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
>
Thinking
</span>
{pending && <ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />}
</button>
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
</div>
)
}
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
<div className="mb-1 mt-1">
<ThinkingDisclosure pending={status?.type === 'running'}>
<div
className={cn(
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
status?.type === 'running' && 'shimmer text-muted-foreground/55'
)}
>
{text}
</div>
</ThinkingDisclosure>
</div>
)
const AssistantActionBar: FC = () => (
<div className="relative h-6 w-13 shrink-0">
<ActionBarPrimitive.Root
autohide="not-last"
autohideFloat="always"
className="absolute inset-0 flex gap-1 text-muted-foreground data-floating:opacity-0 data-floating:transition-opacity data-floating:duration-100 data-floating:group-hover:opacity-100 data-floating:focus-within:opacity-100"
hideWhenRunning
>
<ActionBarPrimitive.Copy asChild copiedDuration={2000}>
<TooltipIconButton className="group/copy" tooltip="Copy">
<CopyIcon className="group-data-copied/copy:hidden" />
<CheckIcon className="hidden group-data-copied/copy:block" />
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
</div>
)
const AssistantFooter: FC = () => {
return (
<div className="flex min-h-6 flex-col items-start gap-1">
<BranchPickerPrimitive.Root
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className={branchButtonClass}>
<ChevronLeftIcon className="size-3.5" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className={branchButtonClass}>
<ChevronRightIcon className="size-3.5" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
<AssistantActionBar />
</div>
)
}
const branchButtonClass =
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
const ActivityTimerBadge: FC<{ seconds: number; tone?: 'muted' | 'warm' }> = ({ seconds, tone = 'muted' }) => (
<span
className={cn(
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
tone === 'warm'
? 'border-primary/20 bg-primary/8 text-primary'
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
)}
>
{formatElapsed(seconds)}
</span>
)
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
data-role="user"
data-slot="aui_user-message-root"
>
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</div>
</MessagePrimitive.Root>
)
}
const EditComposer: FC = () => {
// Editing requires a real onEdit implementation against Hermes history.
// Hide the edit composer until that contract is implemented.
return null
}
@@ -0,0 +1,190 @@
'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { ChevronRight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { cn } from '@/lib/utils'
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const TOOL_SPINNER_INTERVAL_MS = 80
function titleForTool(name: string): string {
return (
name
.split('_')
.filter(Boolean)
.map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
.join(' ') || name
)
}
function toolLabel(name: string, isPending: boolean): string {
const labels: Record<string, { done: string; pending: string }> = {
edit_file: { done: 'Edited file', pending: 'Editing file' },
execute_code: { done: 'Ran code', pending: 'Running code' },
image_generate: { done: 'Generated image', pending: 'Generating image' },
list_files: { done: 'Listed files', pending: 'Listing files' },
read_file: { done: 'Read file', pending: 'Reading file' },
search_files: { done: 'Searched files', pending: 'Searching files' },
session_search_recall: { done: 'Searched session history', pending: 'Searching session history' },
terminal: { done: 'Ran command', pending: 'Running command' },
todo: { done: 'Updated todos', pending: 'Updating todos' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage' },
web_search: { done: 'Searched the web', pending: 'Searching the web' },
write_file: { done: 'Edited file', pending: 'Editing file' }
}
if (labels[name]) {
return isPending ? labels[name].pending : labels[name].done
}
return `${isPending ? 'Using' : 'Used'} ${titleForTool(name)}`
}
function compactPreview(value: unknown, max = 72): string {
const text =
typeof value === 'string'
? value
: value && typeof value === 'object' && 'context' in value
? String((value as { context?: unknown }).context ?? '')
: ''
const oneLine = text.replace(/\s+/g, ' ').trim()
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}` : oneLine
}
function shouldShowInlinePreview(toolName: string): boolean {
return !['image_generate', 'terminal', 'execute_code'].includes(toolName)
}
function contextValue(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value && typeof value === 'object' && 'context' in value) {
return String((value as { context?: unknown }).context ?? '')
}
return ''
}
function prettyJson(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function detailLabel(toolName: string): string {
if (toolName === 'image_generate') {
return 'Prompt'
}
if (toolName === 'web_search') {
return 'Query'
}
if (toolName === 'web_extract') {
return 'URL'
}
if (toolName === 'terminal') {
return 'Command'
}
if (toolName === 'execute_code') {
return 'Code'
}
return 'Input'
}
function detailText(args: unknown, result: unknown): string {
const argContext = contextValue(args)
const resultContext = contextValue(result)
if (resultContext && resultContext !== argContext) {
return resultContext
}
if (argContext) {
return argContext
}
if (result !== undefined) {
return prettyJson(result)
}
return prettyJson(args)
}
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
const [open, setOpen] = useState(false)
const isPending = result === undefined
const [tick, setTick] = useState(0)
const elapsed = useElapsedSeconds(isPending)
const preview = compactPreview(args) || compactPreview(result)
const label = toolLabel(toolName, isPending)
const detail = detailText(args, result)
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
useEffect(() => {
if (!isPending) {
return
}
const id = window.setInterval(() => setTick(value => value + 1), TOOL_SPINNER_INTERVAL_MS)
return () => window.clearInterval(id)
}, [isPending])
return (
<div className="mb-3 mt-1 text-sm text-muted-foreground">
<button
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(v => !v)}
type="button"
>
<ChevronRight
className={cn('shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
size={12}
/>
<span
className={cn('shrink-0 text-xs font-medium text-foreground/70', isPending && 'shimmer text-foreground/55')}
>
{label}
</span>
{preview && shouldShowInlinePreview(toolName) && (
<span className="min-w-0 truncate text-xs text-muted-foreground/80">{preview}</span>
)}
{isPending ? (
<span aria-label="Running" className="ml-1 w-3 shrink-0 text-center text-xs text-muted-foreground/80">
{spinnerFrame}
</span>
) : null}
{isPending && <ToolTimerBadge seconds={elapsed} />}
</button>
{open && (
<div className="ml-4 mt-1 max-w-full whitespace-pre-wrap wrap-anywhere border-l border-border pl-3 text-xs leading-relaxed text-muted-foreground/85">
<span className="mr-1 font-medium text-muted-foreground/70">{detailLabel(toolName)}:</span>
{detail}
</div>
)}
</div>
)
}
const ToolTimerBadge = ({ seconds }: { seconds: number }) => (
<span
className={cn(
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
seconds >= 15
? 'border-primary/20 bg-primary/8 text-primary'
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
)}
>
{formatElapsed(seconds)}
</span>
)
@@ -0,0 +1,9 @@
'use client'
import { type ReactNode } from 'react'
export const ToolGroupRoot = ({ children }: { children: ReactNode }) => (
<div className="my-2 flex flex-col gap-1">{children}</div>
)
export const ToolGroupTrigger = (_props: { count?: number; active?: boolean }) => null
export const ToolGroupContent = ({ children }: { children: ReactNode }) => <div>{children}</div>
@@ -0,0 +1,31 @@
'use client'
import { type ComponentPropsWithRef, forwardRef } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
tooltip: string
side?: 'top' | 'bottom' | 'left' | 'right'
}
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
return (
<Button
size="icon"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon size-6 p-1', className)}
ref={ref}
title={tooltip}
>
{children}
</Button>
)
}
)
TooltipIconButton.displayName = 'TooltipIconButton'
+829
View File
@@ -0,0 +1,829 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import {
ComposerPrimitive,
type Unstable_IconComponent,
type Unstable_MentionCategory,
type Unstable_MentionDirective,
unstable_useMentionAdapter,
useAui,
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import {
ArrowUp,
ChevronDown,
Clipboard,
FileText,
FolderOpen,
ImageIcon,
Link,
type LucideIcon,
MessageSquareText,
Mic,
Plus,
X
} from 'lucide-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '../lib/utils'
import { $composerAttachments, type ComposerAttachment } from '../store/composer'
import { $threadScrolledUp } from '../store/thread-scroll'
import { hermesDirectiveFormatter } from './assistant-ui/directive-text'
import { Button } from './ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from './ui/dropdown-menu'
import { Input } from './ui/input'
type ContextSuggestion = { text: string; display: string; meta?: string }
export type QuickModelOption = {
provider: string
providerName: string
model: string
}
export type ChatBarState = {
model: {
model: string
provider: string
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
}
type ChatBarProps = {
busy: boolean
disabled: boolean
focusKey?: string | null
state: ChatBarState
onCancel: () => void
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onAddUrl?: (url: string) => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
onSubmit: (value: string) => void
}
// Stacked = controls drop below the textarea.
const STACK_AT = 500
const NARROW_VIEWPORT = '(max-width: 680px)'
const EXPAND_HEIGHT_PX = 42
const SHELL =
'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,78%,56rem))] max-w-full -translate-x-1/2'
const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
const COMPOSER_BACKDROP_STYLE = {
backdropFilter: 'blur(.5rem) saturate(1.18)',
WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)'
} satisfies CSSProperties
const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
folder: FolderOpen,
url: Link,
image: ImageIcon,
file: FileText
}
const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
file: FileText,
folder: FolderOpen,
image: ImageIcon,
url: Link
}
const DIRECTIVE_POPOVER_CLASS =
'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),28rem)] max-h-[min(28rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-2xl'
const PROMPT_SNIPPETS = [
{
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
const ASK_PLACEHOLDERS = [
'Hey friend, what can I help with?',
"What's on your mind? I'm here with you.",
'Need a hand? We can take it one step at a time.',
'Want to walk through this bug together?',
"Share what you're working on and we'll figure it out.",
"Tell me where you're stuck and I'll stay with you.",
'Duck mode: gentle debugging, together.'
]
const REF_ITEMS: Unstable_TriggerItem[] = [
{
id: 'file:',
type: 'file',
label: 'File',
description: 'Attach a file path',
metadata: { icon: 'file' }
},
{
id: 'folder:',
type: 'folder',
label: 'Folder',
description: 'Attach a folder path',
metadata: { icon: 'folder' }
},
{
id: 'url:',
type: 'url',
label: 'URL',
description: 'Attach a web page',
metadata: { icon: 'url' }
},
{
id: 'image:',
type: 'image',
label: 'Image',
description: 'Attach an image path',
metadata: { icon: 'image' }
}
]
const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g
function trimPastedEdgeNewlines(text: string): string {
return text.replace(EDGE_NEWLINES_RE, '')
}
export function ChatBar({
busy,
disabled,
focusKey,
state,
onCancel,
onAddContextRef,
onAddUrl,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSubmit
}: ChatBarProps) {
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
const attachments = useStore($composerAttachments)
const scrolledUp = useStore($threadScrolledUp)
const composerRef = useRef<HTMLFormElement | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
const [urlValue, setUrlValue] = useState('')
const [expanded, setExpanded] = useState(false)
const [stack, setStack] = useState(false)
const [askPlaceholder] = useState(
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
)
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
const mention = unstable_useMentionAdapter({
categories: mentionCategories,
includeModelContextTools: false,
formatter: hermesDirectiveFormatter,
iconMap: DIRECTIVE_ICONS,
fallbackIcon: FileText
})
const stacked = expanded || stack
const canSubmit = busy || draft.trim().length > 0 || attachments.length > 0
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
useEffect(() => {
if (!disabled) {
focusInput()
}
}, [disabled, focusKey])
useEffect(() => {
if (urlOpen) {
window.requestAnimationFrame(() => urlInputRef.current?.focus())
}
}, [urlOpen])
useEffect(() => {
if (!draft) {
setExpanded(false)
return
}
if (expanded) {
return
}
const wraps = (textareaRef.current?.scrollHeight ?? 0) > EXPAND_HEIGHT_PX
if (draft.includes('\n') || wraps) {
setExpanded(true)
}
}, [draft, expanded])
useEffect(() => {
const mq = window.matchMedia(NARROW_VIEWPORT)
const update = () => {
const w = composerRef.current?.getBoundingClientRect().width ?? window.innerWidth
setStack(mq.matches || w < STACK_AT)
}
update()
mq.addEventListener('change', update)
const ro = new ResizeObserver(update)
if (composerRef.current) {
ro.observe(composerRef.current)
}
return () => {
mq.removeEventListener('change', update)
ro.disconnect()
}
}, [])
const insertText = (text: string) => {
const sep = draft && !draft.endsWith('\n') ? '\n' : ''
aui.composer().setText(`${draft}${sep}${text}`)
focusInput()
}
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const pastedText = event.clipboardData.getData('text')
if (!pastedText) {
return
}
const trimmedText = trimPastedEdgeNewlines(pastedText)
if (trimmedText === pastedText) {
return
}
event.preventDefault()
const textarea = event.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
const cursor = start + trimmedText.length
aui.composer().setText(nextDraft)
window.requestAnimationFrame(() => {
const current = textareaRef.current
if (!current) {
return
}
current.focus()
current.setSelectionRange(cursor, cursor)
})
}
const submitDraft = () => {
if (busy) {
onCancel()
} else if (draft.trim() || attachments.length > 0) {
onSubmit(draft)
aui.composer().setText('')
}
focusInput()
}
const submitUrl = () => {
const url = urlValue.trim()
if (!url) {
return
}
if (onAddUrl) {
onAddUrl(url)
} else {
insertText(`@url:${url}`)
}
setUrlValue('')
setUrlOpen(false)
}
const contextMenu = (
<ContextMenu
onAddContextRef={onAddContextRef}
onInsertText={insertText}
onOpenUrlDialog={() => setUrlOpen(true)}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
state={state}
/>
)
const controls = <ComposerControls busy={busy} canSubmit={canSubmit} disabled={disabled} state={state} />
const input = (
<ComposerPrimitive.Input
className={cn(
'min-h-8 max-h-37.5 resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-48 flex-1'
)}
disabled={disabled}
onPaste={handlePaste}
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
ref={textareaRef}
rows={1}
unstable_focusOnScrollToBottom={false}
/>
)
return (
<>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
{mentionCategories.length > 0 && (
<DirectivePopover
adapter={mention.adapter}
directive={mention.directive}
fallbackIcon={mention.fallbackIcon ?? FileText}
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
/>
)}
<ComposerPrimitive.Root
className={cn(SHELL, 'group/composer pb-4 pt-2')}
onSubmit={e => {
e.preventDefault()
submitDraft()
}}
ref={composerRef}
>
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-0 bg-linear-to-b from-transparent to-background/55" />
<div className="relative w-full">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[1.25rem] bg-card/1 transition-opacity duration-200 ease-out group-focus-within/composer:opacity-0"
style={COMPOSER_BACKDROP_STYLE}
/>
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer transition-[opacity,background-color,border-color,box-shadow] duration-200 ease-out group-focus-within/composer:border-ring/40 group-focus-within/composer:bg-card group-focus-within/composer:opacity-100 group-focus-within/composer:shadow-composer-focus',
scrolledUp
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
/>
<div
className={cn(
'relative z-1 flex w-full flex-col gap-1.5 overflow-hidden rounded-[1.25rem] px-2 py-1.5 transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
>
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
{stacked ? (
<>
{input}
<div className="flex w-full items-center gap-1.5">
{contextMenu}
{controls}
</div>
</>
) : (
<div className="flex w-full items-end gap-1.5">
{contextMenu}
{input}
{controls}
</div>
)}
</div>
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
<UrlDialog
inputRef={urlInputRef}
onChange={setUrlValue}
onOpenChange={setUrlOpen}
onSubmit={submitUrl}
open={urlOpen}
value={urlValue}
/>
</>
)
}
export function ChatBarFallback() {
return (
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-4 pt-2')}>
<div className="relative h-11 w-full">
<div className="absolute inset-0 rounded-[1.25rem] bg-card/1" style={COMPOSER_BACKDROP_STYLE} />
<div className="absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer" />
</div>
</div>
)
}
function ComposerControls({
busy,
canSubmit,
disabled,
state
}: {
busy: boolean
canSubmit: boolean
disabled: boolean
state: ChatBarState
}) {
return (
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<VoiceButton state={state.voice} />
<Button
aria-label={busy ? 'Stop' : 'Send'}
className={cn(ICON_BTN, 'p-0')}
disabled={disabled || !canSubmit}
type="submit"
>
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
</Button>
</div>
)
}
function VoiceButton({ state }: { state: ChatBarState['voice'] }) {
const aria = state.active ? 'Voice mode active' : 'Voice input'
return (
<Button
aria-label={aria}
className={cn(GHOST_ICON_BTN, 'data-[active=true]:bg-accent data-[active=true]:text-foreground')}
data-active={state.active}
disabled={!state.enabled}
size="icon"
title={aria}
type="button"
variant="ghost"
>
<Mic size={16} />
</Button>
)
}
function ContextMenu({
state,
onAddContextRef,
onInsertText,
onOpenUrlDialog,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages
}: {
state: ChatBarState
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
}) {
const choose = (item: ContextSuggestion) =>
onAddContextRef ? onAddContextRef(item.text, item.display, item.meta) : onInsertText(item.text)
const suggestions = state.tools.suggestions?.slice(0, 8) ?? []
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Plus size={18} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-xs text-muted-foreground">Add context</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folders
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Image from clipboard
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FileText />
<span>Suggested files</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{suggestions.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-muted-foreground">No suggestions</span>
</DropdownMenuItem>
) : (
suggestions.map(item => (
<DropdownMenuItem key={item.text} onSelect={() => choose(item)}>
<FileText />
<span className="min-w-0 flex-1 truncate">{item.display}</span>
{item.meta && <span className="max-w-28 truncate text-xs text-muted-foreground">{item.meta}</span>}
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MessageSquareText />
<span>Prompt snippets</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{PROMPT_SNIPPETS.map(snippet => (
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
{snippet.label}
</ContextMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: {
children: string
disabled?: boolean
icon: LucideIcon
onSelect?: () => void
}) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />
<span>{children}</span>
</DropdownMenuItem>
)
}
function AttachmentList({
attachments,
onRemove
}: {
attachments: ComposerAttachment[]
onRemove?: (id: string) => void
}) {
return (
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
{attachments.map(a => (
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
))}
</div>
)
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const Icon = ATTACHMENT_ICON[attachment.kind]
return (
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
{attachment.previewUrl ? (
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
) : (
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
<Icon className="size-4" />
</span>
)}
<span className="grid min-w-0 gap-0.5">
<span className="truncate font-medium">{attachment.label}</span>
{attachment.detail && (
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
)}
</span>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<X className="size-3.5" />
</button>
)}
</div>
)
}
function DirectivePopover({
adapter,
directive,
fallbackIcon: Fallback,
iconMap
}: {
adapter: Unstable_TriggerAdapter
directive: Unstable_MentionDirective
fallbackIcon: Unstable_IconComponent
iconMap: Record<string, Unstable_IconComponent>
}) {
return (
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
{categories => (
<div className="grid gap-1">
{categories.map(c => (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
categoryId={c.id}
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
key={c.id}
>
<span>{c.label}</span>
<ChevronDown className="-rotate-90 size-3.5 text-muted-foreground" />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
))}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{items => (
<div className="grid gap-1">
<ComposerPrimitive.Unstable_TriggerPopoverBack className="mb-1 text-xs text-muted-foreground hover:text-foreground">
Back
</ComposerPrimitive.Unstable_TriggerPopoverBack>
{items.map((item, index) => {
const Icon = directiveIcon(item, iconMap, Fallback)
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
index={index}
item={item}
key={`${item.type}:${item.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
</span>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
})}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
)
}
function UrlDialog({
inputRef,
onChange,
onOpenChange,
onSubmit,
open,
value
}: {
inputRef: React.RefObject<HTMLInputElement | null>
onChange: (value: string) => void
onOpenChange: (open: boolean) => void
onSubmit: () => void
open: boolean
value: string
}) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add URL Context</DialogTitle>
<DialogDescription>
Hermes will fetch this URL via the existing @url context resolver when you send the prompt.
</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
onSubmit()
}}
>
<Input
onChange={e => onChange(e.target.value)}
placeholder="https://example.com"
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={!value.trim()} type="submit">
Add URL
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
const items = (suggestions ?? [])
.map(s => {
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
if (!match) {
return null
}
const [, type, id] = match
return {
id,
type,
label: s.display || id,
description: s.meta,
metadata: { icon: type }
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
return [
{ id: 'refs', label: 'Hermes refs', items: REF_ITEMS },
...(items.length ? [{ id: 'context', label: 'Suggested files', items }] : [])
]
}
function directiveIcon(
item: Unstable_TriggerItem,
iconMap: Record<string, Unstable_IconComponent>,
fallback: Unstable_IconComponent
): Unstable_IconComponent {
const meta = item.metadata as Record<string, unknown> | undefined
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
return iconMap[key] ?? iconMap[item.type] ?? fallback
}
@@ -0,0 +1,216 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
import { getGlobalModelOptions } from '../hermes'
import { cn } from '../lib/utils'
import { InlineNotice } from './notifications'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton'
const pickerPanelClass = 'max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0'
interface ModelPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
gw?: HermesGateway
sessionId?: string | null
currentModel: string
currentProvider: string
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
}
export function ModelPickerDialog({
open,
onOpenChange,
gw,
sessionId,
currentModel,
currentProvider,
onSelect
}: ModelPickerDialogProps) {
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'],
queryFn: () => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId
})
}
return getGlobalModelOptions()
},
enabled: open
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onOpenChange(false)
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className={pickerPanelClass}>
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle>Switch model</DialogTitle>
<DialogDescription className="font-mono text-xs leading-relaxed">
current: {optionsModel || currentModel || '(unknown)'}
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
</DialogDescription>
</DialogHeader>
<Command className="rounded-none bg-card">
<CommandInput autoFocus placeholder="Filter providers and models..." />
<CommandList className="max-h-96">
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
<ModelResults
currentModel={optionsModel || currentModel}
currentProvider={optionsProvider || currentProvider}
error={error}
loading={loading}
onSelectModel={selectModel}
providers={providers}
/>
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
</label>
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function ModelResults({
loading,
error,
providers,
currentModel,
currentProvider,
onSelectModel
}: {
loading: boolean
error: string | null
providers: ModelOptionProvider[]
currentModel: string
currentProvider: string
onSelectModel: (provider: ModelOptionProvider, model: string) => void
}) {
if (loading) {
return <LoadingResults />
}
if (error) {
return (
<div className="px-3 py-3">
<InlineNotice kind="error" title="Could not load models">
{error}
</InlineNotice>
</div>
)
}
if (providers.length === 0) {
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
}
return (
<>
{providers.map(provider => {
const models = provider.models ?? []
if (models.length === 0) {
return null
}
return (
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
{provider.warning && (
<div className="px-2 pb-2">
<InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning">
{provider.warning}
</InlineNotice>
</div>
)}
{models.map(model => {
const isCurrent = model === currentModel && provider.slug === currentProvider
return (
<CommandItem
className={cn(
'pl-6 font-mono',
isCurrent &&
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
)}
key={`${provider.slug}:${model}`}
onSelect={() => onSelectModel(provider, model)}
value={`${provider.name} ${provider.slug} ${model}`}
>
<span className="min-w-0 flex-1 truncate">{model}</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
</>
)
}
function LoadingResults() {
return (
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
{Array.from({ length: 4 }, (_, rowIndex) => (
<div className="rounded-sm py-1.5 pl-6 pr-2" key={rowIndex}>
<Skeleton className={cn('h-5', rowIndex % 3 === 0 ? 'w-3/5' : rowIndex % 3 === 1 ? 'w-4/5' : 'w-1/2')} />
</div>
))}
</CommandGroup>
)
}
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
return (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{provider.name}</span>
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
</span>
</span>
)
}
@@ -0,0 +1,139 @@
import { useStore } from '@nanostores/react'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { cn } from '@/lib/utils'
import {
$notifications,
type AppNotification,
clearNotifications,
dismissNotification,
type NotificationKind
} from '@/store/notifications'
const tone: Record<
NotificationKind,
{
icon: LucideIcon
variant: 'default' | 'destructive' | 'warning' | 'success'
}
> = {
error: {
icon: AlertCircle,
variant: 'destructive'
},
warning: {
icon: AlertTriangle,
variant: 'warning'
},
info: {
icon: Info,
variant: 'default'
},
success: {
icon: CheckCircle2,
variant: 'success'
}
}
export function NotificationStack() {
const notifications = useStore($notifications)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
if (notifications.length <= 1) {
setExpanded(false)
}
}, [notifications.length])
if (notifications.length === 0) {
return null
}
const [latest, ...olderNotifications] = notifications
const overflowCount = olderNotifications.length
return (
<div
aria-label="Notifications"
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
<NotificationItem notification={latest} />
{overflowCount > 0 && (
<div className="pointer-events-auto flex min-h-8 items-center justify-between rounded-lg border border-border bg-card/80 px-3 text-xs text-muted-foreground shadow-xs">
<button
className="bg-transparent font-medium text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(value => !value)}
type="button"
>
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
</button>
<button
className="bg-transparent text-muted-foreground hover:text-foreground"
onClick={clearNotifications}
type="button"
>
Clear all
</button>
</div>
)}
{expanded &&
olderNotifications.map(notification => <NotificationItem key={notification.id} notification={notification} />)}
</div>
)
}
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
return (
<Alert
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
className="pointer-events-auto grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5 shadow-lg"
role={notification.kind === 'error' ? 'alert' : 'status'}
variant={styles.variant}
>
<Icon />
<div className="col-start-2 min-w-0">
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
<AlertDescription className="col-start-auto">
<p className="m-0">{notification.message}</p>
</AlertDescription>
</div>
<button
aria-label="Dismiss notification"
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dismissNotification(notification.id)}
type="button"
>
<X className="size-3.5" />
</button>
</Alert>
)
}
export function InlineNotice({
kind = 'info',
title,
children,
className
}: {
kind?: NotificationKind
title?: string
children: ReactNode
className?: string
}) {
const styles = tone[kind]
const Icon = styles.icon
return (
<Alert className={cn('min-w-0', className)} role={kind === 'error' ? 'alert' : 'status'} variant={styles.variant}>
<Icon />
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription className={cn(!title && 'row-start-1')}>{children}</AlertDescription>
</Alert>
)
}
@@ -0,0 +1,307 @@
'use client'
import { ChevronDown, FolderOpen, GitBranch, Pencil } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export type SessionInspectorProps = {
open: boolean
cwd: string
branch: string
busy: boolean
modelLabel: string
modelTitle?: string
providerName?: string
personality: string
personalities: string[]
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
onOpenModelPicker?: () => void
onSelectPersonality?: (name: string) => void
}
export const SESSION_INSPECTOR_WIDTH = '14rem'
// Quiet button-like row: invisible until hovered/focused.
const quietControl =
'rounded-md border border-transparent bg-transparent transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30'
// Bleed interactive rows leftwards by 6px so the hover ring doesn't look
// indented relative to the section labels above them.
const bleed = '-ml-1.5 w-[calc(100%_+_0.375rem)]'
const disabledRow = 'disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
export const SessionInspector: FC<SessionInspectorProps> = ({
open,
cwd,
branch,
busy,
modelLabel,
modelTitle,
providerName,
personality,
personalities,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onSelectPersonality
}) => (
<aside
aria-hidden={!open}
className={cn(
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
)}
data-open={open}
>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
<WorkspaceSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
<AgentSection
current={personality}
label={modelLabel}
onOpen={onOpenModelPicker}
onSelect={onSelectPersonality}
options={personalities}
providerName={providerName}
title={modelTitle}
/>
</div>
</aside>
)
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
}
function WorkspaceSection({
cwd,
branch,
busy,
onChangeCwd,
onBrowseCwd
}: {
cwd: string
branch: string
busy: boolean
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(cwd)
const inputRef = useRef<HTMLInputElement | null>(null)
const canChange = Boolean(onChangeCwd) && !busy
const beginEdit = () => canChange && setEditing(true)
useEffect(() => {
if (!editing) {
setDraft(cwd)
}
}, [cwd, editing])
useEffect(() => {
if (editing) {
inputRef.current?.focus()
}
}, [editing])
const apply = () => {
const next = draft.trim()
if (next && next !== cwd) {
onChangeCwd?.(next)
}
setEditing(false)
}
const branchLabel = branch.trim()
return (
<section className="grid gap-1.5 py-1.5">
<SectionLabel>cwd</SectionLabel>
{editing ? (
<Input
className="h-7 bg-background px-2 font-mono text-[0.6875rem]"
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
} else if (e.key === 'Escape') {
e.preventDefault()
setEditing(false)
}
}}
placeholder="/path/to/project"
ref={inputRef}
value={draft}
/>
) : (
<div
className={cn(
quietControl,
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
)}
>
<button
aria-label="Browse workspace folder"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
disabled={!canChange || !onBrowseCwd}
onClick={onBrowseCwd}
type="button"
>
<FolderOpen className="size-3" />
</button>
<button
aria-label="Edit working directory"
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
dir="rtl"
disabled={!canChange}
onClick={beginEdit}
type="button"
>
<span dir="ltr">{compactPath(cwd) || '—'}</span>
</button>
{canChange && (
<button
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
onClick={beginEdit}
tabIndex={-1}
type="button"
>
<Pencil className="size-3" />
</button>
)}
</div>
)}
{branchLabel && (
<div className={cn(quietControl, bleed, 'flex min-w-0 items-center gap-1 px-1.5 py-1 text-[0.6875rem]')}>
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
</div>
)}
</section>
)
}
function AgentSection({
label: modelLabel,
onOpen,
providerName,
current,
options,
onSelect
}: {
label: string
title?: string
providerName?: string
onOpen?: () => void
current: string
options: string[]
onSelect?: (name: string) => void
}) {
const [open, setOpen] = useState(false)
const merged = useMemo(
() => [...new Set(['default', ...options, current].map(s => s?.trim().toLowerCase()).filter(Boolean))],
[current, options]
)
const activeKey = (current || 'default').trim().toLowerCase()
const personalityLabel = current ? titleize(current) : 'Default'
return (
<section className="grid gap-1.5 py-1.5">
<SectionLabel>Agent</SectionLabel>
<button
aria-label="Change model"
className={cn(quietControl, bleed, disabledRow, 'group grid gap-px px-1.5 py-1 text-left')}
disabled={!onOpen}
onClick={onOpen}
type="button"
>
<span className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">
{modelLabel || 'Hermes'}
</span>
{onOpen && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</span>
{providerName && <span className="truncate text-[0.625rem] text-muted-foreground/70">{providerName}</span>}
</button>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild disabled={!onSelect}>
<button
aria-label="Change personality"
className={cn(quietControl, bleed, disabledRow, 'group flex items-center gap-1.5 px-1.5 py-1 text-left')}
type="button"
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground">
{personalityLabel}
</span>
{onSelect && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-52 border-border/70 bg-popover/95 shadow-md"
side="bottom"
sideOffset={6}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">Personality</DropdownMenuLabel>
<DropdownMenuSeparator />
{merged.map(name => (
<DropdownMenuCheckboxItem
checked={activeKey === name}
className="text-xs text-muted-foreground focus:text-foreground"
key={name}
onSelect={e => {
e.preventDefault()
onSelect?.(name)
setOpen(false)
}}
>
{titleize(name)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</section>
)
}
function compactPath(path: string): string {
if (!path) {
return ''
}
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
const parts = normalized.split('/').filter(Boolean)
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
}
function titleize(value: string): string {
return value
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/(^|\s)\S/g, m => m.toUpperCase())
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative grid w-full grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 gap-y-1 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-xs [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'border-border',
destructive:
'border-destructive/35 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-destructive)_4%)] [&>svg]:text-destructive',
warning:
'border-primary/30 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-primary)_4%)] [&>svg]:text-primary',
success:
'border-primary/25 bg-[color-mix(in_srgb,var(--dt-card)_97%,var(--dt-primary)_3%)] [&>svg]:text-primary'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return <div className={cn(alertVariants({ variant }), className)} data-slot="alert" role="alert" {...props} />
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight text-foreground', className)}
data-slot="alert-title"
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'col-start-2 grid justify-items-start gap-1 text-muted-foreground [&_p]:leading-relaxed',
className
)}
data-slot="alert-description"
{...props}
/>
)
}
export { Alert, AlertDescription, AlertTitle }
+62
View File
@@ -0,0 +1,62 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
data-size={size}
data-slot="button"
data-variant={variant}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,27 @@
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
className={cn(
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current"
data-slot="checkbox-indicator"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+111
View File
@@ -0,0 +1,111 @@
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
data-slot="command"
{...props}
/>
)
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
data-slot="command-input"
{...props}
/>
</div>
)
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
className={cn('max-h-100 overflow-y-auto overflow-x-hidden', className)}
data-slot="command-list"
{...props}
/>
)
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
className="py-6 text-center text-sm text-muted-foreground"
data-slot="command-empty"
{...props}
/>
)
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
className={cn(
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-10 **:[[cmdk-group-heading]]:bg-popover **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
className
)}
data-slot="command-group"
{...props}
/>
)
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
className={cn('-mx-1 h-px bg-border', className)}
data-slot="command-separator"
{...props}
/>
)
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
className
)}
data-slot="command-item"
{...props}
/>
)
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
data-slot="command-shortcut"
{...props}
/>
)
}
export {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
}
+121
View File
@@ -0,0 +1,121 @@
import { XIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute right-3 top-3 rounded-md p-1.5 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
data-slot="dialog-close-button"
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)}
data-slot="dialog-header"
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
data-slot="dialog-footer"
{...props}
/>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('text-base font-semibold tracking-tight text-foreground', className)}
data-slot="dialog-title"
{...props}
/>
)
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
data-slot="dialog-description"
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
}
@@ -0,0 +1,217 @@
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
/>
)
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border', className)}
data-slot="dropdown-menu-separator"
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
className={cn(
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
data-slot="input"
type={type}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,43 @@
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} data-slot="scroll-area" {...props}>
<ScrollAreaPrimitive.Viewport className="size-full outline-none" data-slot="scroll-area-viewport">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none p-px transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
data-slot="scroll-area-scrollbar"
orientation={orientation}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
className="relative flex-1 rounded-full bg-muted-foreground/30 hover:bg-muted-foreground/45"
data-slot="scroll-area-thumb"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+85
View File
@@ -0,0 +1,85 @@
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
import { Select as SelectPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
className
)}
data-slot="select-trigger"
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-60" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-80 max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
data-slot="select-content"
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' && 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)'
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className
)}
data-slot="select-item"
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
@@ -0,0 +1,26 @@
import { Separator as SeparatorPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
{...props}
/>
)
}
export { Separator }
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { XIcon } from 'lucide-react'
import { Dialog as SheetPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="sheet-overlay"
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
side === 'top' &&
'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
className
)}
data-slot="sheet-content"
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col gap-1.5 p-4', className)} data-slot="sheet-header" {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} data-slot="sheet-footer" {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
className={cn('font-semibold text-foreground', className)}
data-slot="sheet-title"
{...props}
/>
)
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
data-slot="sheet-description"
{...props}
/>
)
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger }
+681
View File
@@ -0,0 +1,681 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
className={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', className)}
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style
} as React.CSSProperties
}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
className={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)}
data-slot="sidebar"
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
<SheetContent
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
data-mobile="true"
data-sidebar="sidebar"
data-slot="sidebar"
side={side}
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE
} as React.CSSProperties
}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-side={side}
data-slot="sidebar"
data-state={state}
data-variant={variant}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
data-slot="sidebar-gap"
/>
<div
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+0.125rem)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
data-slot="sidebar-container"
{...props}
>
<div
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
data-sidebar="sidebar"
data-slot="sidebar-inner"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
className={cn('size-7', className)}
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={event => {
onClick?.(event)
toggleSidebar()
}}
size="icon"
variant="ghost"
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
aria-label="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
data-sidebar="rail"
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
title="Toggle Sidebar"
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
className={cn(
'relative flex w-full flex-1 flex-col bg-background',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
data-slot="sidebar-inset"
{...props}
/>
)
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
className={cn('h-8 w-full bg-background shadow-none', className)}
data-sidebar="input"
data-slot="sidebar-input"
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-2 p-2', className)}
data-sidebar="header"
data-slot="sidebar-header"
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-2 p-2', className)}
data-sidebar="footer"
data-slot="sidebar-footer"
{...props}
/>
)
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn('mx-2 w-auto bg-sidebar-border', className)}
data-sidebar="separator"
data-slot="sidebar-separator"
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
data-sidebar="group"
data-slot="sidebar-group"
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'div'
return (
<Comp
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
data-sidebar="group-label"
data-slot="sidebar-group-label"
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
className={cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
data-sidebar="group-action"
data-slot="sidebar-group-action"
{...props}
/>
)
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('w-full text-sm', className)}
data-sidebar="group-content"
data-slot="sidebar-group-content"
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
data-sidebar="menu"
data-slot="sidebar-menu"
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
className={cn('group/menu-item relative', className)}
data-sidebar="menu-item"
data-slot="sidebar-menu-item"
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-accent))]'
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : 'button'
const { isMobile, state } = useSidebar()
const button = (
<Comp
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
data-active={isActive}
data-sidebar="menu-button"
data-size={size}
data-slot="sidebar-menu-button"
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent align="center" hidden={state !== 'collapsed' || isMobile} side="right" {...tooltip} />
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
className={cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0',
className
)}
data-sidebar="menu-action"
data-slot="sidebar-menu-action"
{...props}
/>
)
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
data-sidebar="menu-badge"
data-slot="sidebar-menu-badge"
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
data-sidebar="menu-skeleton"
data-slot="sidebar-menu-skeleton"
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
data-sidebar="menu-sub"
data-slot="sidebar-menu-sub"
{...props}
/>
)
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
className={cn('group/menu-sub-item relative', className)}
data-sidebar="menu-sub-item"
data-slot="sidebar-menu-sub-item"
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : 'a'
return (
<Comp
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
data-active={isActive}
data-sidebar="menu-sub-button"
data-size={size}
data-slot="sidebar-menu-sub-button"
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
}
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('animate-pulse rounded-md bg-accent', className)} data-slot="skeleton" {...props} />
}
export { Skeleton }
+26
View File
@@ -0,0 +1,26 @@
import { Switch as SwitchPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-input shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary dark:bg-input/80',
className
)}
data-slot="switch"
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
data-slot="switch-thumb"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+36
View File
@@ -0,0 +1,36 @@
import { Tabs as TabsPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root className={cn('flex flex-col gap-2', className)} data-slot="tabs" {...props} />
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
data-slot="tabs-list"
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
'inline-flex h-7 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
data-slot="tabs-trigger"
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger }
@@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn(
'min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20',
className
)}
data-slot="textarea"
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,42 @@
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className
)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }