refactor: replace button elements with Button component for improved consistency and styling across multiple UI components

This commit is contained in:
Anish Sarkar 2026-05-14 14:17:44 +05:30
parent 23e05acc7c
commit 3d42712b3f
27 changed files with 401 additions and 424 deletions

View file

@ -96,13 +96,14 @@ export function CommunityPromptsContent() {
{prompt.prompt} {prompt.prompt}
</p> </p>
{prompt.prompt.length > 100 && ( {prompt.prompt.length > 100 && (
<button <Button
type="button" type="button"
variant="link"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)} onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer" className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
> >
{expandedId === prompt.id ? "See less" : "See more"} {expandedId === prompt.id ? "See less" : "See more"}
</button> </Button>
)} )}
</div> </div>
<Button <Button

View file

@ -97,17 +97,18 @@ function HotkeyRow({
<RotateCcw className="size-3" /> <RotateCcw className="size-3" />
</Button> </Button>
)} )}
<button <Button
ref={inputRef} ref={inputRef}
type="button" type="button"
variant="ghost"
title={recording ? "Press shortcut keys" : "Click to edit shortcut"} title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
onClick={() => setRecording(true)} onClick={() => setRecording(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)} onBlur={() => setRecording(false)}
className={ className={
recording recording
? "flex h-7 items-center rounded-md border border-transparent bg-primary/5 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0" ? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
: "flex h-7 cursor-pointer items-center rounded-md border border-transparent bg-transparent outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0" : "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
} }
> >
{recording ? ( {recording ? (
@ -115,7 +116,7 @@ function HotkeyRow({
) : ( ) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" /> <ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)} )}
</button> </Button>
</div> </div>
</div> </div>
); );

View file

@ -277,22 +277,25 @@ export function PromptsContent() {
{prompt.prompt} {prompt.prompt}
</p> </p>
{prompt.prompt.length > 100 && ( {prompt.prompt.length > 100 && (
<button <Button
type="button" type="button"
variant="link"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)} onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer" className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
> >
{expandedId === prompt.id ? "See less" : "See more"} {expandedId === prompt.id ? "See less" : "See more"}
</button> </Button>
)} )}
</div> </div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0"> <div className="hidden group-hover:flex items-center gap-1 shrink-0">
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
title={prompt.is_public ? "Make private" : "Share with community"} title={prompt.is_public ? "Make private" : "Share with community"}
onClick={() => handleTogglePublic(prompt)} onClick={() => handleTogglePublic(prompt)}
disabled={togglingPublicIds.has(prompt.id)} disabled={togglingPublicIds.has(prompt.id)}
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-accent-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none" className="size-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
> >
{togglingPublicIds.has(prompt.id) ? ( {togglingPublicIds.has(prompt.id) ? (
<Spinner className="size-3.5" /> <Spinner className="size-3.5" />
@ -301,7 +304,7 @@ export function PromptsContent() {
) : ( ) : (
<Globe className="size-3.5" /> <Globe className="size-3.5" />
)} )}
</button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View file

@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
<Button disabled className="text-sm h-9 min-w-[180px]"> <Button disabled className="text-sm h-9 min-w-[180px]">
Grant permissions to continue Grant permissions to continue
</Button> </Button>
<button <Button
type="button" type="button"
variant="link"
onClick={handleSkip} onClick={handleSkip}
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors" className="mx-auto h-auto px-0 py-0 text-xs text-muted-foreground hover:text-foreground"
> >
Skip for now Skip for now
</button> </Button>
</> </>
)} )}
</div> </div>

View file

@ -74,10 +74,11 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
isAlreadyReverted && "opacity-70" isAlreadyReverted && "opacity-70"
)} )}
> >
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => setIsExpanded((v) => !v)} onClick={() => setIsExpanded((v) => !v)}
className="flex w-full items-start gap-3 p-3 text-left hover:bg-accent hover:text-accent-foreground" className="h-auto w-full items-start justify-start gap-3 p-3 text-left hover:bg-accent hover:text-accent-foreground"
aria-expanded={isExpanded} aria-expanded={isExpanded}
> >
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted"> <div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
@ -119,7 +120,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
isExpanded && "rotate-90" isExpanded && "rotate-90"
)} )}
/> />
</button> </Button>
{isExpanded && ( {isExpanded && (
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3"> <div className="flex flex-col gap-3 border-t bg-muted/20 p-3">

View file

@ -4,8 +4,9 @@ import type { ImageMessagePartComponent } from "@assistant-ui/react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { ImageIcon, ImageOffIcon } from "lucide-react"; import { ImageIcon, ImageOffIcon } from "lucide-react";
import NextImage from "next/image"; import NextImage from "next/image";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { memo, type PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", { const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
@ -44,8 +45,14 @@ function ImageRoot({ className, variant, size, children, ...props }: ImageRootPr
); );
} }
type ImagePreviewProps = Omit<React.ComponentProps<"img">, "children"> & { type ImagePreviewProps = Omit<
React.ComponentProps<"img">,
"children" | "height" | "onError" | "onLoad" | "src" | "width"
> & {
containerClassName?: string; containerClassName?: string;
onError?: React.ReactEventHandler<HTMLImageElement>;
onLoad?: React.ReactEventHandler<HTMLImageElement>;
src?: string;
}; };
function ImagePreview({ function ImagePreview({
@ -57,18 +64,17 @@ function ImagePreview({
src, src,
...props ...props
}: ImagePreviewProps) { }: ImagePreviewProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined); const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined); const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined);
const imageSrc = src ?? "";
const loaded = loadedSrc === src; const loaded = imageSrc !== "" && loadedSrc === imageSrc;
const error = errorSrc === src; const error = imageSrc === "" || errorSrc === imageSrc;
useEffect(() => { useEffect(() => {
if (typeof src === "string" && imgRef.current?.complete && imgRef.current.naturalWidth > 0) { setLoadedSrc((current) => (current === imageSrc ? current : undefined));
setLoadedSrc(src); setErrorSrc((current) => (current === imageSrc ? current : undefined));
} }, [imageSrc]);
}, [src]);
return ( return (
<div data-slot="image-preview" className={cn("relative min-h-32", containerClassName)}> <div data-slot="image-preview" className={cn("relative min-h-32", containerClassName)}>
@ -87,55 +93,22 @@ function ImagePreview({
> >
<ImageOffIcon className="size-8 text-muted-foreground" /> <ImageOffIcon className="size-8 text-muted-foreground" />
</div> </div>
) : isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
ref={imgRef}
src={src}
alt={alt}
className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
onLoad={(e) => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.(e);
}}
onError={(e) => {
if (typeof src === "string") setErrorSrc(src);
onError?.(e);
}}
{...props}
/>
) : ( ) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
// <img
// ref={imgRef}
// src={src}
// alt={alt}
// className={cn("block h-auto w-full object-contain", !loaded && "invisible", className)}
// onLoad={(e) => {
// if (typeof src === "string") setLoadedSrc(src);
// onLoad?.(e);
// }}
// onError={(e) => {
// if (typeof src === "string") setErrorSrc(src);
// onError?.(e);
// }}
// {...props}
// />
<NextImage <NextImage
fill fill
src={src || ""} src={imageSrc}
alt={alt} alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)} className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => { onLoad={(event) => {
if (typeof src === "string") setLoadedSrc(src); setLoadedSrc(imageSrc);
onLoad?.(); onLoad?.(event);
}} }}
onError={() => { onError={(event) => {
if (typeof src === "string") setErrorSrc(src); setErrorSrc(imageSrc);
onError?.(); onError?.(event);
}} }}
unoptimized={false} unoptimized={isDataOrBlobUrl(imageSrc)}
{...props} {...props}
/> />
)} )}
@ -196,44 +169,26 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
return ( return (
<> <>
<button <Button
type="button" type="button"
variant="ghost"
onClick={handleOpen} onClick={handleOpen}
className="aui-image-zoom-trigger cursor-zoom-in border-0 bg-transparent p-0 text-left" className="aui-image-zoom-trigger h-auto cursor-zoom-in border-0 bg-transparent p-0 text-left hover:bg-transparent"
aria-label="Click to zoom image" aria-label="Click to zoom image"
> >
{children} {children}
</button> </Button>
{isMounted && {isMounted &&
isOpen && isOpen &&
createPortal( createPortal(
<button <Button
type="button" type="button"
variant="ghost"
data-slot="image-zoom-overlay" data-slot="image-zoom-overlay"
className="aui-image-zoom-overlay fade-in fixed inset-0 z-50 flex animate-in cursor-zoom-out items-center justify-center border-0 bg-black/80 p-0 duration-200" className="aui-image-zoom-overlay fade-in fixed inset-0 z-50 h-auto w-auto animate-in cursor-zoom-out items-center justify-center rounded-none border-0 bg-black/80 p-0 duration-200 hover:bg-black/80 focus-visible:ring-0"
onClick={handleClose} onClick={handleClose}
aria-label="Close zoomed image" aria-label="Close zoomed image"
> >
{/** biome-ignore lint/performance/noImgElement: <explanation> */}
{isDataOrBlobUrl(src) ? (
// biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
<img
data-slot="image-zoom-content"
src={src}
alt={alt}
className="aui-image-zoom-content fade-in zoom-in-95 max-h-[90vh] max-w-[90vw] animate-in object-contain duration-200"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
handleClose();
}
}}
/>
) : (
<NextImage <NextImage
data-slot="image-zoom-content" data-slot="image-zoom-content"
fill fill
@ -245,10 +200,9 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
e.stopPropagation(); e.stopPropagation();
handleClose(); handleClose();
}} }}
unoptimized={false} unoptimized={isDataOrBlobUrl(src)}
/> />
)} </Button>,
</button>,
document.body document.body
)} )}
</> </>

View file

@ -13,6 +13,7 @@ import {
Presentation, Presentation,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@ -243,9 +244,11 @@ export function GoogleDriveFolderTree({
)} )}
> >
{isFolder ? ( {isFolder ? (
<button <Button
type="button" type="button"
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer" variant="ghost"
size="icon"
className="h-3 w-3 shrink-0 cursor-pointer bg-transparent p-0 hover:bg-transparent sm:h-4 sm:w-4"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleFolder(item); toggleFolder(item);
@ -259,7 +262,7 @@ export function GoogleDriveFolderTree({
) : ( ) : (
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" /> <ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
)} )}
</button> </Button>
) : ( ) : (
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" /> <span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
)} )}
@ -290,13 +293,14 @@ export function GoogleDriveFolderTree({
</div> </div>
{isFolder ? ( {isFolder ? (
<button <Button
type="button" type="button"
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer" variant="ghost"
className="h-auto min-w-0 flex-1 cursor-pointer justify-start truncate bg-transparent p-0 text-left text-xs hover:bg-transparent sm:text-sm"
onClick={() => toggleFolder(item)} onClick={() => toggleFolder(item)}
> >
{item.name} {item.name}
</button> </Button>
) : ( ) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0"> <span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{item.name} {item.name}
@ -332,13 +336,14 @@ export function GoogleDriveFolderTree({
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20" className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
/> />
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" /> <HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" />
<button <Button
type="button" type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left" variant="ghost"
className="h-auto cursor-pointer truncate bg-transparent p-0 text-left text-xs font-semibold hover:bg-transparent sm:text-sm"
onClick={() => toggleFolderSelection("root", "My Drive")} onClick={() => toggleFolderSelection("root", "My Drive")}
> >
My Drive My Drive
</button> </Button>
</div> </div>
</div> </div>

View file

@ -241,9 +241,11 @@ export function DocumentsFilters({
aria-label={t("filter_placeholder")} aria-label={t("filter_placeholder")}
/> />
{Boolean(searchValue) && ( {Boolean(searchValue) && (
<button <Button
type="button" type="button"
className="absolute right-1 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Clear filter" aria-label="Clear filter"
onClick={() => { onClick={() => {
onSearch(""); onSearch("");
@ -251,7 +253,7 @@ export function DocumentsFilters({
}} }}
> >
<X size={14} strokeWidth={2} aria-hidden="true" /> <X size={14} strokeWidth={2} aria-hidden="true" />
</button> </Button>
)} )}
</div> </div>

View file

@ -85,14 +85,35 @@ export function FolderPickerDialog({
const FolderIcon = isExpanded ? FolderOpen : Folder; const FolderIcon = isExpanded ? FolderOpen : Folder;
return [ return [
<button <div key={f.id} className="relative w-full">
key={f.id} {hasChildren && (
<Button
type="button" type="button"
variant="ghost"
size="icon"
disabled={isDisabled}
className="absolute top-1/2 z-10 size-4 -translate-y-1/2 p-0"
style={{ left: `${depth * 16 + 8}px` }}
aria-label={isExpanded ? `Collapse ${f.name}` : `Expand ${f.name}`}
onClick={(e) => {
e.stopPropagation();
toggleExpand(f.id);
}}
>
{isExpanded ? (
<ChevronDown data-icon="inline-start" />
) : (
<ChevronRight data-icon="inline-start" />
)}
</Button>
)}
<Button
type="button"
variant="ghost"
disabled={isDisabled} disabled={isDisabled}
className={cn( className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors", "h-auto w-full justify-start gap-1.5 px-2 py-1.5 text-sm font-normal",
isSelected && "bg-accent text-accent-foreground", isSelected && "bg-accent text-accent-foreground",
!isSelected && !isDisabled && "hover:bg-accent hover:text-accent-foreground",
isDisabled && "cursor-not-allowed opacity-40" isDisabled && "cursor-not-allowed opacity-40"
)} )}
style={{ paddingLeft: `${depth * 16 + 8}px` }} style={{ paddingLeft: `${depth * 16 + 8}px` }}
@ -100,27 +121,11 @@ export function FolderPickerDialog({
if (!isDisabled) setSelectedId(f.id); if (!isDisabled) setSelectedId(f.id);
}} }}
> >
{hasChildren ? ( <span className="size-4 shrink-0" />
<button <FolderIcon data-icon="inline-start" className="text-muted-foreground" />
type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
toggleExpand(f.id);
}}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="h-4 w-4 shrink-0" />
)}
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{f.name}</span> <span className="truncate">{f.name}</span>
</button>, </Button>
</div>,
...(isExpanded ? renderPickerLevel(f.id, depth + 1) : []), ...(isExpanded ? renderPickerLevel(f.id, depth + 1) : []),
]; ];
}); });
@ -143,19 +148,19 @@ export function FolderPickerDialog({
</DialogHeader> </DialogHeader>
<div className="max-h-[300px] overflow-y-auto rounded-md border p-1"> <div className="max-h-[300px] overflow-y-auto rounded-md border p-1">
<button <Button
type="button" type="button"
variant="ghost"
className={cn( className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors", "h-auto w-full justify-start gap-1.5 px-2 py-1.5 text-sm font-normal",
selectedId === null && "bg-accent text-accent-foreground", selectedId === null && "bg-accent text-accent-foreground"
selectedId !== null && "hover:bg-accent hover:text-accent-foreground"
)} )}
onClick={() => setSelectedId(null)} onClick={() => setSelectedId(null)}
> >
<span className="h-4 w-4 shrink-0" /> <span className="size-4 shrink-0" />
<Home className="h-4 w-4 shrink-0 text-muted-foreground" /> <Home data-icon="inline-start" className="text-muted-foreground" />
<span>Root</span> <span>Root</span>
</button> </Button>
{renderPickerLevel(null, 1)} {renderPickerLevel(null, 1)}
</div> </div>

View file

@ -177,12 +177,13 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{versions.map((v) => ( {versions.map((v) => (
<button <Button
key={v.version_number} key={v.version_number}
type="button" type="button"
variant="ghost"
onClick={() => handleSelectVersion(v.version_number)} onClick={() => handleSelectVersion(v.version_number)}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none w-full", "h-auto w-full justify-start gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:outline-none",
selectedVersion === v.version_number selectedVersion === v.version_number
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@ -197,7 +198,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>} {v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
</div> </div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" /> <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View file

@ -11,20 +11,16 @@ import {
} from "@/lib/citations/citation-parser"; } from "@/lib/citations/citation-parser";
/** /**
* Plate inline-void node modeling a single `[citation:...]` reference. * Plate inline-void node for one `[citation:...]` reference.
* * Inline voids keep the citation chip atomic while preserving caret behavior
* Modeled after the existing `MentionPlugin` pattern in * around the surrounding text.
* `inline-mention-editor.tsx` the only confirmed pattern in this repo
* for non-text inline UI. Inline-void elements satisfy Slate's invariant
* that the editor renders both atomic widgets and surrounding text
* cleanly without breaking selection / caret semantics.
*/ */
export type CitationElementNode = { export type CitationElementNode = {
type: "citation"; type: "citation";
kind: "chunk" | "doc" | "url"; kind: "chunk" | "doc" | "url";
chunkId?: number; chunkId?: number;
url?: string; url?: string;
/** Original `[citation:...]` substring for traceability/debugging. */ /** Original literal token that produced this citation node. */
rawText: string; rawText: string;
children: [{ text: "" }]; children: [{ text: "" }];
}; };
@ -62,17 +58,14 @@ const CitationPlugin = createPlatePlugin({
}, },
}); });
/** Plugin kit shape used elsewhere in the editor. */
export const CitationKit = [CitationPlugin]; export const CitationKit = [CitationPlugin];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slate value transform — runs after MarkdownPlugin.deserialize // Slate value transform
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Structural shapes used by the value transform. We cannot use Plate's // Local structural shapes keep the recursive walker readable without forcing
// generic Element / Text type predicates directly because `Descendant` is a // Plate's broad Descendant union into narrower generic predicates.
// constrained union and our predicates would over-narrow. Casting through
// these row types keeps the walker readable without fighting the types.
type SlateText = { text: string } & Record<string, unknown>; type SlateText = { text: string } & Record<string, unknown>;
type SlateElement = { type?: string; children: Descendant[] } & Record<string, unknown>; type SlateElement = { type?: string; children: Descendant[] } & Record<string, unknown>;
@ -89,19 +82,15 @@ function asElement(node: Descendant): SlateElement {
} }
/** /**
* Element types whose subtrees we MUST NOT inject citation void elements * Subtrees that should keep citation tokens as text:
* into. Each rationale documented in the citation plan: * - Code nodes preserve source text and reject inline void children.
* - `KEYS.codeBlock` / `code_line` Plate's schema rejects inline elements * - Link nodes already render as anchors; citation chips are interactive
* inside code containers; the user expects literal text inside code. * shadcn Button-based controls, so injecting them would nest interactions.
* - `KEYS.link` `<button>` inside `<a>` is invalid HTML and the link
* swallows the citation click. Mirrors the `<a>` skip in
* `MarkdownViewer`.
*/ */
const SKIP_SUBTREE_TYPES = new Set<string>([KEYS.codeBlock, "code_line", KEYS.link]); const SKIP_SUBTREE_TYPES = new Set<string>([KEYS.codeBlock, "code_line", KEYS.link]);
/** /**
* Build the marks portion of a Slate text node so we can preserve formatting * Preserve text marks such as bold and italic when splitting around citations.
* (bold/italic/etc.) on the surrounding text fragments after we split.
*/ */
function copyMarks(textNode: SlateText): Record<string, unknown> { function copyMarks(textNode: SlateText): Record<string, unknown> {
const { text: _text, ...marks } = textNode; const { text: _text, ...marks } = textNode;
@ -131,9 +120,7 @@ function makeCitationElement(
} }
/** /**
* Re-extract the raw `[citation:...]` substrings that produced each parsed * Keep each original citation token on the generated node for diagnostics.
* segment, in source order. Lets us preserve the original literal for
* `rawText` on the inline-void element.
*/ */
function extractRawCitationMatches(text: string): string[] { function extractRawCitationMatches(text: string): string[] {
const matches: string[] = []; const matches: string[] = [];
@ -159,9 +146,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
let pendingText: string | null = null; let pendingText: string | null = null;
const flushText = () => { const flushText = () => {
// Slate inline-void adjacency: emit an empty text node (with copied // Inline voids need text siblings, even at text boundaries.
// marks) when the citation appears at the very start/end of the text
// node so neighbours of the void always have a text sibling.
out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant); out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant);
pendingText = null; pendingText = null;
}; };
@ -174,8 +159,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
const raw = rawMatches[citationIdx] ?? ""; const raw = rawMatches[citationIdx] ?? "";
out.push(makeCitationElement(raw, segment) as unknown as Descendant); out.push(makeCitationElement(raw, segment) as unknown as Descendant);
citationIdx += 1; citationIdx += 1;
// Always reset pendingText so the next loop iteration emits a // Ensure a trailing text sibling if the citation ends the node.
// trailing empty text node if no further plain text follows.
pendingText = ""; pendingText = "";
} }
} }
@ -206,12 +190,9 @@ function transformChildren(children: Descendant[], urlMap: CitationUrlMap): Desc
} }
/** /**
* Walk a deserialized Slate value and replace every `[citation:...]` * Replace citation tokens in a deserialized Slate tree with citation inline
* substring with a `citation` inline-void element. URL placeholders * void nodes. URL placeholders from `preprocessCitationMarkdown` are resolved
* created by `preprocessCitationMarkdown` are resolved through `urlMap`. * through `urlMap`; skipped subtrees are returned unchanged.
*
* Subtrees of `code_block`, `code_line`, and `link` are returned as-is
* see `SKIP_SUBTREE_TYPES` above.
*/ */
export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] { export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] {
return transformChildren(value, urlMap); return transformChildren(value, urlMap);

View file

@ -5,6 +5,7 @@ import { ArrowUpIcon, Globe, Paperclip, SquareIcon } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react"; import { type FC, useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode"; import { useAnonymousMode } from "@/contexts/anonymous-mode";
@ -185,18 +186,19 @@ export const FreeComposer: FC = () => {
/> />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
onClick={handleUploadClick} onClick={handleUploadClick}
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors", "h-auto gap-1.5 rounded-md px-2 py-1 text-xs transition-colors",
"text-muted-foreground hover:text-accent-foreground hover:bg-accent", "text-muted-foreground hover:text-accent-foreground hover:bg-accent",
hasUploadedDoc && "text-primary" hasUploadedDoc && "text-primary"
)} )}
> >
<Paperclip className="size-3.5" /> <Paperclip className="size-3.5" />
{hasUploadedDoc ? "1/1" : "Upload"} {hasUploadedDoc ? "1/1" : "Upload"}
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{hasUploadedDoc {hasUploadedDoc

View file

@ -3,6 +3,7 @@
import { OctagonAlert, Orbit, X } from "lucide-react"; import { OctagonAlert, Orbit, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface QuotaWarningBannerProps { interface QuotaWarningBannerProps {
@ -71,13 +72,15 @@ export function QuotaWarningBanner({
</Link>{" "} </Link>{" "}
for $5 of premium credit. for $5 of premium credit.
</p> </p>
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
className="text-amber-400 hover:text-amber-600 dark:hover:text-amber-200" className="size-6 text-amber-400 hover:bg-transparent hover:text-amber-600 dark:hover:text-amber-200"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </Button>
</div> </div>
</div> </div>
); );

View file

@ -2,6 +2,7 @@
import { IconMessageCircleQuestion } from "@tabler/icons-react"; import { IconMessageCircleQuestion } from "@tabler/icons-react";
import Link from "next/link"; import Link from "next/link";
import type React from "react"; import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function CTAHomepage() { export function CTAHomepage() {
@ -22,15 +23,16 @@ export function CTAHomepage() {
</p> </p>
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4"> <div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
<Link href="/contact"> <Button
<button asChild
type="button" variant="ghost"
className="mt-8 flex space-x-2 items-center group text-base px-4 py-2 rounded-lg text-black dark:text-white border border-neutral-200 dark:border-neutral-800 shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset]" className="mt-8 h-auto gap-2 rounded-lg border border-neutral-200 px-4 py-2 text-base text-black shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset] dark:border-neutral-800 dark:text-white"
> >
<Link href="/contact" className="group">
<span>Talk to us</span> <span>Talk to us</span>
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" /> <IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
</button>
</Link> </Link>
</Button>
</div> </div>
</div> </div>
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14"> {/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">

View file

@ -4,6 +4,7 @@ import { AnimatePresence, motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer"; import Balancer from "react-wrap-balancer";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -184,24 +185,26 @@ function GetStartedButton() {
if (isGoogleAuth) { if (isGoogleAuth) {
return ( return (
<button <Button
type="button" type="button"
variant="ghost"
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800" className="h-14 w-full cursor-pointer gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
> >
<GoogleLogo className="h-5 w-5" /> <GoogleLogo className="h-5 w-5" />
<span>Continue with Google</span> <span>Continue with Google</span>
</button> </Button>
); );
} }
return ( return (
<Link <Button
href="/login" asChild
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black" variant="ghost"
className="h-14 w-full rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-black sm:w-52 dark:bg-white dark:text-black dark:hover:bg-white"
> >
Get Started <Link href="/login">Get Started</Link>
</Link> </Button>
); );
} }
@ -212,35 +215,40 @@ function DownloadButton() {
if (!primary) { if (!primary) {
return ( return (
<a <Button
href={fallbackUrl} asChild
target="_blank" variant="ghost"
rel="noopener noreferrer" className="h-14 w-full gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
className="flex h-14 w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
> >
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer">
<Download className="size-4" /> <Download className="size-4" />
Download for {os} Download for {os}
</a> </a>
</Button>
); );
} }
return ( return (
<div className="flex h-14 w-full items-stretch sm:w-auto"> <div className="flex h-14 w-full items-stretch sm:w-auto">
<a <Button
href={primary.url} asChild
className="flex flex-1 items-center justify-center gap-2 rounded-l-lg border border-r-0 border-neutral-200 bg-white px-5 text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-[0.99] hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800" variant="ghost"
className="h-auto flex-1 gap-2 rounded-l-lg rounded-r-none border border-r-0 border-neutral-200 bg-white px-5 text-base font-medium text-neutral-700 shadow-sm transition duration-150 active:scale-[0.99] hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
> >
<a href={primary.url}>
<Download className="size-4 shrink-0" /> <Download className="size-4 shrink-0" />
Download for {os} Download for {os}
</a> </a>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <Button
type="button" type="button"
className="flex items-center justify-center rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800" variant="ghost"
className="h-auto rounded-l-none rounded-r-lg border border-neutral-200 bg-white px-2.5 text-neutral-500 shadow-sm transition duration-150 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800"
> >
<ChevronDown className="size-4" /> <ChevronDown className="size-4" />
</button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64"> <DropdownMenuContent align="end" className="w-64">
{alternatives.map((asset) => ( {alternatives.map((asset) => (
@ -284,11 +292,12 @@ const BrowserWindow = () => {
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4"> <div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
{TAB_ITEMS.map((item, index) => ( {TAB_ITEMS.map((item, index) => (
<React.Fragment key={item.title}> <React.Fragment key={item.title}>
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => setSelectedIndex(index)} onClick={() => setSelectedIndex(index)}
className={cn( className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950", "h-auto shrink-0 gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
selectedIndex === index && selectedIndex === index &&
!item.featured && !item.featured &&
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900", "bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
@ -311,7 +320,7 @@ const BrowserWindow = () => {
<TooltipContent side="bottom">Desktop app only</TooltipContent> <TooltipContent side="bottom">Desktop app only</TooltipContent>
</Tooltip> </Tooltip>
)} )}
</button> </Button>
{index !== TAB_ITEMS.length - 1 && ( {index !== TAB_ITEMS.length - 1 && (
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" /> <div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)} )}
@ -354,13 +363,14 @@ const BrowserWindow = () => {
</p> </p>
</div> </div>
</div> </div>
<button <Button
type="button" type="button"
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full" variant="ghost"
className="h-auto w-full cursor-pointer rounded-none bg-neutral-50 p-2 hover:bg-neutral-50 sm:p-3 dark:bg-neutral-950 dark:hover:bg-neutral-950"
onClick={open} onClick={open}
> >
<TabVideo src={selectedItem.src} /> <TabVideo key={selectedItem.src} src={selectedItem.src} />
</button> </Button>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
@ -385,7 +395,7 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
if (!video) return; if (!video) return;
video.currentTime = 0; video.currentTime = 0;
video.play().catch(() => {}); video.play().catch(() => {});
}, [src]); }, []);
const handleCanPlay = useCallback(() => { const handleCanPlay = useCallback(() => {
setHasLoaded(true); setHasLoaded(true);

View file

@ -7,6 +7,7 @@ import { SignInButton } from "@/components/auth/sign-in-button";
import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface NavItem { interface NavItem {
@ -99,7 +100,7 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
onMouseEnter={() => setHovered(idx)} onMouseEnter={() => setHovered(idx)}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300" className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
key={`link=${idx}`} key={navItem.link}
href={navItem.link} href={navItem.link}
> >
{hovered === idx && ( {hovered === idx && (
@ -179,10 +180,12 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
<Logo className="h-8 w-8 rounded-md" disableLink /> <Logo className="h-8 w-8 rounded-md" disableLink />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span> <span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</Link> </Link>
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
className="relative z-50 flex items-center justify-center p-2 -mr-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation" className="relative z-50 -mr-2 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 touch-manipulation"
aria-label={open ? "Close menu" : "Open menu"} aria-label={open ? "Close menu" : "Open menu"}
> >
{open ? ( {open ? (
@ -190,7 +193,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
) : ( ) : (
<IconMenu2 className="h-6 w-6 text-black dark:text-white" /> <IconMenu2 className="h-6 w-6 text-black dark:text-white" />
)} )}
</button> </Button>
</div> </div>
<AnimatePresence> <AnimatePresence>
@ -202,9 +205,9 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
transition={{ duration: 0.2, ease: "easeOut" }} transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute inset-x-0 top-full mt-1 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-xl bg-white/90 backdrop-blur-xl border border-white/20 shadow-2xl px-4 py-6 dark:bg-neutral-950/90 dark:border-neutral-800/50" className="absolute inset-x-0 top-full mt-1 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-xl bg-white/90 backdrop-blur-xl border border-white/20 shadow-2xl px-4 py-6 dark:bg-neutral-950/90 dark:border-neutral-800/50"
> >
{navItems.map((navItem: NavItem, idx: number) => ( {navItems.map((navItem: NavItem) => (
<Link <Link
key={`link=${idx}`} key={navItem.link}
href={navItem.link} href={navItem.link}
className="relative text-neutral-600 dark:text-neutral-300" className="relative text-neutral-600 dark:text-neutral-300"
> >

View file

@ -3,6 +3,7 @@
import { Settings, Trash2, Users } from "lucide-react"; import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -120,11 +121,13 @@ export function SearchSpaceAvatar({
); );
const avatarButton = ( const avatarButton = (
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all select-none", "relative rounded-lg font-semibold text-white transition-all select-none",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses, sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background" isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
@ -144,7 +147,7 @@ export function SearchSpaceAvatar({
<Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} /> <Users className={cn(size === "sm" ? "h-2 w-2" : "h-2.5 w-2.5")} />
</span> </span>
)} )}
</button> </Button>
); );
const menuItems = ( const menuItems = (

View file

@ -347,8 +347,9 @@ export function AllSharedChatsSidebarContent({
return ( return (
<div key={thread.id} className="group/item relative w-full"> <div key={thread.id} className="group/item relative w-full">
{isMobile ? ( {isMobile ? (
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
if (wasLongPress()) return; if (wasLongPress()) return;
handleThreadClick(thread.id); handleThreadClick(thread.id);
@ -361,7 +362,7 @@ export function AllSharedChatsSidebarContent({
onTouchMove={longPressHandlers.onTouchMove} onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy} disabled={isBusy}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left", "h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground", "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground", isActive && "bg-accent text-accent-foreground",
@ -369,16 +370,17 @@ export function AllSharedChatsSidebarContent({
)} )}
> >
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </Button>
) : ( ) : (
<Tooltip delayDuration={600}> <Tooltip delayDuration={600}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => handleThreadClick(thread.id)} onClick={() => handleThreadClick(thread.id)}
disabled={isBusy} disabled={isBusy}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left", "h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground", "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground", isActive && "bg-accent text-accent-foreground",
@ -386,7 +388,7 @@ export function AllSharedChatsSidebarContent({
)} )}
> >
<span className="truncate">{thread.title || "New Chat"}</span> <span className="truncate">{thread.title || "New Chat"}</span>
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="start"> <TooltipContent side="bottom" align="start">
<p> <p>

View file

@ -56,19 +56,20 @@ export function ChatListItem({
return ( return (
<div className="group/item relative w-full"> <div className="group/item relative w-full">
<button <Button
type="button" type="button"
variant="ghost"
onClick={handleClick} onClick={handleClick}
{...(isMobile ? longPressHandlers : {})} {...(isMobile ? longPressHandlers : {})}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left", "h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
"group-hover/item:bg-accent group-hover/item:text-accent-foreground", "group-hover/item:bg-accent group-hover/item:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground" isActive && "bg-accent text-accent-foreground"
)} )}
> >
<span className="truncate">{animatedName}</span> <span className="truncate">{animatedName}</span>
</button> </Button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div <div

View file

@ -575,14 +575,15 @@ export function InboxSidebarContent({
{t("filter") || "Filter"} {t("filter") || "Filter"}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
setActiveFilter("all"); setActiveFilter("all");
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "all" activeFilter === "all"
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
@ -593,15 +594,16 @@ export function InboxSidebarContent({
<span>{t("all") || "All"}</span> <span>{t("all") || "All"}</span>
</span> </span>
{activeFilter === "all" && <Check className="h-4 w-4" />} {activeFilter === "all" && <Check className="h-4 w-4" />}
</button> </Button>
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
setActiveFilter("unread"); setActiveFilter("unread");
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "unread" activeFilter === "unread"
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
@ -612,16 +614,17 @@ export function InboxSidebarContent({
<span>{t("unread") || "Unread"}</span> <span>{t("unread") || "Unread"}</span>
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "unread" && <Check className="h-4 w-4" />}
</button> </Button>
{activeTab === "status" && ( {activeTab === "status" && (
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
setActiveFilter("errors"); setActiveFilter("errors");
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "errors" activeFilter === "errors"
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
@ -632,7 +635,7 @@ export function InboxSidebarContent({
<span>{t("errors_only") || "Errors only"}</span> <span>{t("errors_only") || "Errors only"}</span>
</span> </span>
{activeFilter === "errors" && <Check className="h-4 w-4" />} {activeFilter === "errors" && <Check className="h-4 w-4" />}
</button> </Button>
)} )}
</div> </div>
</div> </div>
@ -642,14 +645,15 @@ export function InboxSidebarContent({
{t("sources") || "Sources"} {t("sources") || "Sources"}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
setSelectedSource(null); setSelectedSource(null);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedSource === null selectedSource === null
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
@ -660,17 +664,18 @@ export function InboxSidebarContent({
<span>{t("all_sources") || "All sources"}</span> <span>{t("all_sources") || "All sources"}</span>
</span> </span>
{selectedSource === null && <Check className="h-4 w-4" />} {selectedSource === null && <Check className="h-4 w-4" />}
</button> </Button>
{statusSourceOptions.map((source) => ( {statusSourceOptions.map((source) => (
<button <Button
key={source.key} key={source.key}
type="button" type="button"
variant="ghost"
onClick={() => { onClick={() => {
setSelectedSource(source.key); setSelectedSource(source.key);
setFilterDrawerOpen(false); setFilterDrawerOpen(false);
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "h-auto w-full justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedSource === source.key selectedSource === source.key
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
@ -681,7 +686,7 @@ export function InboxSidebarContent({
<span>{source.displayName}</span> <span>{source.displayName}</span>
</span> </span>
{selectedSource === source.key && <Check className="h-4 w-4" />} {selectedSource === source.key && <Check className="h-4 w-4" />}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
@ -922,11 +927,12 @@ export function InboxSidebarContent({
{activeTab === "status" ? ( {activeTab === "status" ? (
<Tooltip delayDuration={600}> <Tooltip delayDuration={600}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead} disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden" className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
> >
<div className="shrink-0">{getStatusIcon(item)}</div> <div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
@ -942,7 +948,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)} {convertRenderedToDisplay(item.message)}
</p> </p>
</div> </div>
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]"> <TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p> <p className="font-medium">{item.title}</p>
@ -952,11 +958,12 @@ export function InboxSidebarContent({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead} disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden" className="h-auto flex-1 justify-start gap-3 overflow-hidden bg-transparent p-0 text-left hover:bg-transparent"
> >
<div className="shrink-0">{getStatusIcon(item)}</div> <div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
@ -972,7 +979,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)} {convertRenderedToDisplay(item.message)}
</p> </p>
</div> </div>
</button> </Button>
)} )}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10"> <div className="flex items-center justify-end gap-1.5 shrink-0 w-10">

View file

@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useIsAnonymous } from "@/contexts/anonymous-mode"; import { useIsAnonymous } from "@/contexts/anonymous-mode";
@ -204,13 +205,14 @@ export function Sidebar({
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen} alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
action={ action={
onViewAllSharedChats ? ( onViewAllSharedChats ? (
<button <Button
type="button" type="button"
variant="ghost"
onClick={onViewAllSharedChats} onClick={onViewAllSharedChats}
className="text-xs font-medium text-muted-foreground/60 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none" className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
> >
{!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")} {!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
</button> </Button>
) : undefined ) : undefined
} }
> >
@ -260,13 +262,14 @@ export function Sidebar({
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen} alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
action={ action={
onViewAllPrivateChats ? ( onViewAllPrivateChats ? (
<button <Button
type="button" type="button"
variant="ghost"
onClick={onViewAllPrivateChats} onClick={onViewAllPrivateChats}
className="text-xs font-medium text-muted-foreground/60 hover:text-muted-foreground transition-colors whitespace-nowrap cursor-pointer bg-transparent border-none p-0 focus:outline-none" className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
> >
{!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")} {!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
</button> </Button>
) : undefined ) : undefined
} }
> >

View file

@ -12,18 +12,12 @@ interface SidebarButtonProps {
isCollapsed?: boolean; isCollapsed?: boolean;
isActive?: boolean; isActive?: boolean;
badge?: React.ReactNode; badge?: React.ReactNode;
/** Overlay in the top-right corner of the collapsed icon (e.g. status badge) */
collapsedOverlay?: React.ReactNode; collapsedOverlay?: React.ReactNode;
/** Custom icon node for collapsed mode — overrides the default <Icon> rendering */
collapsedIconNode?: React.ReactNode; collapsedIconNode?: React.ReactNode;
/** Custom icon node for expanded mode — overrides the default <Icon> rendering */
expandedIconNode?: React.ReactNode; expandedIconNode?: React.ReactNode;
/** Optional inline trailing content shown in expanded mode */
trailingContent?: React.ReactNode; trailingContent?: React.ReactNode;
/** Optional tooltip content that replaces the default label tooltip */
tooltipContent?: React.ReactNode; tooltipContent?: React.ReactNode;
className?: string; className?: string;
/** Extra attributes spread onto the inner <button> (e.g. data-joyride) */
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>; buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
} }

View file

@ -29,6 +29,7 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useLocaleContext } from "@/contexts/LocaleContext"; import { useLocaleContext } from "@/contexts/LocaleContext";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
@ -204,11 +205,12 @@ export function SidebarUserProfile({
<div className="border-t px-1.5 py-2"> <div className="border-t px-1.5 py-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
className={cn( className={cn(
"mx-auto flex h-9 w-9 items-center justify-center rounded-full", "mx-auto h-9 w-9 rounded-full p-0",
"transition-opacity hover:opacity-90", "transition-opacity hover:bg-transparent hover:opacity-90",
"focus:outline-none focus-visible:outline-none", "focus:outline-none focus-visible:outline-none",
"data-[state=open]:opacity-90" "data-[state=open]:opacity-90"
)} )}
@ -220,7 +222,7 @@ export function SidebarUserProfile({
size="md" size="md"
/> />
<span className="sr-only">{displayName}</span> <span className="sr-only">{displayName}</span>
</button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="right" align="end" sideOffset={8}> <DropdownMenuContent className="w-48" side="right" align="end" sideOffset={8}>
@ -367,10 +369,11 @@ export function SidebarUserProfile({
<div className="border-t"> <div className="border-t">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
className={cn( className={cn(
"flex w-full items-center gap-2 px-2 py-3 text-left", "h-auto w-full justify-start gap-2 rounded-none px-2 py-3 text-left",
"hover:bg-accent transition-colors", "hover:bg-accent transition-colors",
"focus:outline-none focus-visible:outline-none", "focus:outline-none focus-visible:outline-none",
"data-[state=open]:bg-transparent" "data-[state=open]:bg-transparent"
@ -386,7 +389,7 @@ export function SidebarUserProfile({
{/* Chevron icon */} {/* Chevron icon */}
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" /> <ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
</button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48" side="top" align="center" sideOffset={4}> <DropdownMenuContent className="w-48" side="top" align="center" sideOffset={4}>

View file

@ -10,6 +10,7 @@ import {
type Tab, type Tab,
tabsAtom, tabsAtom,
} from "@/atoms/tabs/tabs.atom"; } from "@/atoms/tabs/tabs.atom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TabBarProps { interface TabBarProps {
@ -189,22 +190,25 @@ export function TabBar({
)} )}
/> />
) : null} ) : null}
<button <div
type="button"
data-tab-id={tab.id} data-tab-id={tab.id}
className="group relative h-full w-[180px] shrink-0"
>
<Button
type="button"
variant="ghost"
onClick={() => handleTabClick(tab)} onClick={() => handleTabClick(tab)}
onMouseEnter={() => setHoveredTabIndex(index)} onMouseEnter={() => setHoveredTabIndex(index)}
onMouseLeave={() => setHoveredTabIndex(null)} onMouseLeave={() => setHoveredTabIndex(null)}
className={cn( className={cn(
"group relative flex h-full items-center px-3 w-[180px] min-h-0 overflow-hidden text-[13px] font-medium rounded-md transition-colors duration-150 shrink-0", "h-full w-full justify-start overflow-hidden px-3 text-left text-[13px] font-medium transition-colors duration-150",
isActive isActive
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground group-hover:bg-accent group-hover:text-accent-foreground group-focus-within:bg-accent group-focus-within:text-accent-foreground"
)} )}
> >
<span className="block min-w-0 flex-1 whitespace-nowrap overflow-hidden text-left"> <span className="block min-w-0 flex-1 truncate text-left">{tab.title}</span>
{tab.title} </Button>
</span>
{/* Hover-only gradient + close overlay (sidebar pattern) — keeps pill width fixed and avoids ellipsis shift. */} {/* Hover-only gradient + close overlay (sidebar pattern) — keeps pill width fixed and avoids ellipsis shift. */}
<div <div
className={cn( className={cn(
@ -213,23 +217,19 @@ export function TabBar({
"bg-gradient-to-l from-accent from-60% to-transparent" "bg-gradient-to-l from-accent from-60% to-transparent"
)} )}
> >
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */} <Button
<span type="button"
role="button" variant="ghost"
tabIndex={0} size="icon"
onClick={(e) => handleTabClose(e, tab.id)} onClick={(e) => handleTabClose(e, tab.id)}
onKeyDown={(e) => { onMouseEnter={() => setHoveredTabIndex(index)}
if (e.key === "Enter" || e.key === " ") { onMouseLeave={() => setHoveredTabIndex(null)}
e.preventDefault(); className="pointer-events-auto size-4 rounded-full p-0.5 hover:bg-accent hover:text-accent-foreground"
handleTabClose(e as unknown as React.MouseEvent, tab.id);
}
}}
className="pointer-events-auto rounded-full p-0.5 transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<X className="size-3" /> <X data-icon="inline-start" />
</span> </Button>
</div>
</div> </div>
</button>
</Fragment> </Fragment>
); );
})} })}
@ -243,14 +243,16 @@ export function TabBar({
"before:bg-gradient-to-r before:from-transparent before:to-panel" "before:bg-gradient-to-r before:from-transparent before:to-panel"
)} )}
> >
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={onNewChat} onClick={onNewChat}
className="flex h-8 w-8 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:bg-accent hover:text-accent-foreground" className="size-8 shrink-0 text-muted-foreground transition-all duration-150 hover:bg-accent hover:text-accent-foreground"
title="New Chat" title="New Chat"
> >
<Plus className="size-4" /> <Plus data-icon="inline-start" />
</button> </Button>
</div> </div>
)} )}
</div> </div>

View file

@ -143,18 +143,20 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{hasPublicSnapshots && ( {hasPublicSnapshots && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => onClick={() =>
setSearchSpaceSettingsDialog({ setSearchSpaceSettingsDialog({
open: true, open: true,
initialTab: "public-links", initialTab: "public-links",
}) })
} }
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-accent hover:text-accent-foreground transition-colors" className="size-8 bg-muted/50 hover:bg-accent hover:text-accent-foreground"
> >
<Earth className="h-4 w-4 text-muted-foreground" /> <Earth data-icon="inline-start" className="text-muted-foreground" />
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Manage public links</TooltipContent> <TooltipContent>Manage public links</TooltipContent>
</Tooltip> </Tooltip>
@ -185,14 +187,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
const Icon = option.icon; const Icon = option.icon;
return ( return (
<button <Button
type="button" type="button"
variant="ghost"
key={option.value} key={option.value}
onClick={() => handleVisibilityChange(option.value)} onClick={() => handleVisibilityChange(option.value)}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-colors", "h-auto w-full justify-start gap-2.5 whitespace-normal px-2.5 py-2 font-normal",
"hover:bg-accent hover:text-accent-foreground cursor-pointer",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected && "bg-accent text-accent-foreground" isSelected && "bg-accent text-accent-foreground"
)} )}
> >
@ -204,7 +205,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
> >
<Icon <Icon
className={cn( className={cn(
"size-4 block", "block",
isSelected ? "text-primary dark:text-white" : "text-muted-foreground" isSelected ? "text-primary dark:text-white" : "text-muted-foreground"
)} )}
/> />
@ -224,7 +225,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{option.description} {option.description}
</p> </p>
</div> </div>
</button> </Button>
); );
})} })}
@ -234,19 +235,18 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<div className="border-t border-popover-border my-1" /> <div className="border-t border-popover-border my-1" />
{/* Public Link Option */} {/* Public Link Option */}
<button <Button
type="button" type="button"
variant="ghost"
onClick={handleCreatePublicLink} onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot} disabled={isCreatingSnapshot}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-colors", "h-auto w-full justify-start gap-2.5 whitespace-normal px-2.5 py-2 font-normal",
"hover:bg-accent hover:text-accent-foreground cursor-pointer", "disabled:cursor-not-allowed"
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed"
)} )}
> >
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5"> <div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted dark:bg-white/5">
<Earth className="size-4 block text-muted-foreground" /> <Earth className="block text-muted-foreground" />
</div> </div>
<div className="flex-1 text-left min-w-0"> <div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@ -258,7 +258,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
Creates a shareable snapshot of this chat Creates a shareable snapshot of this chat
</p> </p>
</div> </div>
</button> </Button>
</> </>
)} )}
</div> </div>

View file

@ -115,13 +115,15 @@ function buildFolderTree(entries: FolderEntry[]): FolderTreeNode[] {
function flattenTree( function flattenTree(
nodes: FolderTreeNode[], nodes: FolderTreeNode[],
depth = 0 depth = 0,
): { name: string; isFolder: boolean; depth: number; size?: number }[] { parentPath = ""
const items: { name: string; isFolder: boolean; depth: number; size?: number }[] = []; ): { name: string; isFolder: boolean; depth: number; path: string; size?: number }[] {
const items: { name: string; isFolder: boolean; depth: number; path: string; size?: number }[] = [];
for (const node of nodes) { for (const node of nodes) {
items.push({ name: node.name, isFolder: node.isFolder, depth, size: node.size }); const path = parentPath ? `${parentPath}/${node.name}` : node.name;
items.push({ name: node.name, isFolder: node.isFolder, depth, path, size: node.size });
if (node.isFolder && node.children.length > 0) { if (node.isFolder && node.children.length > 0) {
items.push(...flattenTree(node.children, depth + 1)); items.push(...flattenTree(node.children, depth + 1, path));
} }
} }
return items; return items;
@ -537,37 +539,39 @@ export function DocumentUploadTab({
isElectron ? ( isElectron ? (
<div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div> <div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div>
) : ( ) : (
<button <Button
type="button" type="button"
className="w-full text-xs h-8 flex items-center justify-center gap-1.5 rounded-md border border-dashed border-muted-foreground/30 text-muted-foreground hover:text-accent-foreground hover:border-foreground/50 transition-colors" variant="ghost"
className="h-8 w-full gap-1.5 rounded-md border border-dashed border-muted-foreground/30 px-0 text-xs text-muted-foreground transition-colors hover:border-foreground/50 hover:bg-transparent hover:text-accent-foreground"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
Add more files Add more files
</button> </Button>
) )
) : ( ) : (
// biome-ignore lint/a11y/useSemanticElements: cannot use <button> here because the contents include nested interactive elements (renderBrowseButton renders a Button), which would be invalid HTML. <div className="flex w-full flex-col items-center gap-4 bg-transparent px-4 py-12 select-none">
<div {isElectron ? (
role="button" <div className="flex w-full flex-col items-center gap-4">
tabIndex={0} <Upload className="h-10 w-10 text-muted-foreground" />
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none" <div className="text-center space-y-1.5">
onClick={() => { <p className="text-base font-medium">{t("select_files_or_folder")}</p>
if (!isElectron) fileInputRef.current?.click(); <p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
}} </div>
onKeyDown={(e) => { </div>
if (e.key === "Enter" || e.key === " ") { ) : (
e.preventDefault(); <Button
if (!isElectron) fileInputRef.current?.click(); type="button"
} variant="ghost"
}} className="h-auto w-full flex-col gap-4 whitespace-normal bg-transparent p-0 text-foreground hover:bg-transparent hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
> >
<Upload className="h-10 w-10 text-muted-foreground" /> <Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5"> <div className="text-center space-y-1.5">
<p className="text-base font-medium"> <p className="text-base font-medium">{t("tap_select_files_or_folder")}</p>
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p> <p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div> </div>
</Button>
)}
<fieldset <fieldset
className="w-full mt-1 border-none p-0 m-0" className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -649,9 +653,9 @@ export function DocumentUploadTab({
<div className="max-h-[160px] sm:max-h-[200px] overflow-y-auto -mx-1"> <div className="max-h-[160px] sm:max-h-[200px] overflow-y-auto -mx-1">
{folderUpload {folderUpload
? folderTreeItems.map((item, i) => ( ? folderTreeItems.map((item) => (
<div <div
key={`${item.depth}-${i}-${item.name}`} key={item.path}
className="flex items-center gap-1.5 py-0.5 px-2" className="flex items-center gap-1.5 py-0.5 px-2"
style={{ paddingLeft: `${item.depth * 16 + 8}px` }} style={{ paddingLeft: `${item.depth * 16 + 8}px` }}
> >
@ -726,10 +730,11 @@ export function DocumentUploadTab({
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="font-medium text-sm px-1">{t("processing_mode")}</p> <p className="font-medium text-sm px-1">{t("processing_mode")}</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <Button
type="button" type="button"
onClick={() => setProcessingMode("basic")} onClick={() => setProcessingMode("basic")}
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${ variant="ghost"
className={`h-auto w-full items-start justify-start whitespace-normal rounded-lg border p-3 text-left transition-colors hover:bg-transparent hover:text-foreground ${
processingMode === "basic" processingMode === "basic"
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground/50" : "border-border hover:border-muted-foreground/50"
@ -742,11 +747,12 @@ export function DocumentUploadTab({
<p className="font-medium text-sm">{t("basic_mode")}</p> <p className="font-medium text-sm">{t("basic_mode")}</p>
<p className="text-xs text-muted-foreground">{t("basic_mode_desc")}</p> <p className="text-xs text-muted-foreground">{t("basic_mode_desc")}</p>
</div> </div>
</button> </Button>
<button <Button
type="button" type="button"
onClick={() => setProcessingMode("premium")} onClick={() => setProcessingMode("premium")}
className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition-colors ${ variant="ghost"
className={`h-auto w-full items-start justify-start whitespace-normal rounded-lg border p-3 text-left transition-colors hover:bg-transparent hover:text-foreground ${
processingMode === "premium" processingMode === "premium"
? "border-amber-500 bg-amber-500/5" ? "border-amber-500 bg-amber-500/5"
: "border-border hover:border-muted-foreground/50" : "border-border hover:border-muted-foreground/50"
@ -759,7 +765,7 @@ export function DocumentUploadTab({
<p className="font-medium text-sm">{t("premium_mode")}</p> <p className="font-medium text-sm">{t("premium_mode")}</p>
<p className="text-xs text-muted-foreground">{t("premium_mode_desc")}</p> <p className="text-xs text-muted-foreground">{t("premium_mode_desc")}</p>
</div> </div>
</button> </Button>
</div> </div>
</div> </div>

View file

@ -22,7 +22,7 @@ import {
} from "platejs/react"; } from "platejs/react";
import * as React from "react"; import * as React from "react";
import { buttonVariants } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
const popoverVariants = cva( const popoverVariants = cva(
@ -116,13 +116,9 @@ export function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarStat
input input
) : ( ) : (
<div className="box-content flex items-center"> <div className="box-content flex items-center">
<button <Button size="sm" variant="ghost" type="button" {...editButtonProps}>
className={buttonVariants({ size: "sm", variant: "ghost" })}
type="button"
{...editButtonProps}
>
Edit link Edit link
</button> </Button>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
@ -130,16 +126,9 @@ export function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarStat
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<button <Button size="sm" variant="ghost" type="button" {...unlinkButtonProps}>
className={buttonVariants({
size: "sm",
variant: "ghost",
})}
type="button"
{...unlinkButtonProps}
>
<Unlink width={18} /> <Unlink width={18} />
</button> </Button>
</div> </div>
); );
@ -158,29 +147,22 @@ export function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarStat
function LinkOpenButton() { function LinkOpenButton() {
const editor = useEditorRef(); const editor = useEditorRef();
const selection = useEditorSelection(); useEditorSelection();
// biome-ignore lint/correctness/useExhaustiveDependencies: selection triggers recalculation of link attributes
const attributes = React.useMemo(() => {
const entry = editor.api.node<TLinkElement>({ const entry = editor.api.node<TLinkElement>({
match: { type: editor.getType(KEYS.link) }, match: { type: editor.getType(KEYS.link) },
}); });
if (!entry) { const attributes = entry ? getLinkAttributes(editor, entry[0]) : {};
return {}; const href = typeof attributes.href === "string" ? attributes.href : undefined;
}
const [element] = entry;
return getLinkAttributes(editor, element);
}, [editor, selection]);
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: <a> with spread attributes has dynamic href <Button
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label needed for icon-only link size="sm"
<a variant="ghost"
{...attributes} type="button"
className={buttonVariants({ onClick={() => {
size: "sm", if (href) window.open(href, "_blank", "noopener,noreferrer");
variant: "ghost", }}
})}
onMouseOver={(e) => { onMouseOver={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -188,10 +170,9 @@ function LinkOpenButton() {
e.stopPropagation(); e.stopPropagation();
}} }}
aria-label="Open link in a new tab" aria-label="Open link in a new tab"
target="_blank" disabled={!href}
rel="noopener noreferrer"
> >
<ExternalLink width={18} /> <ExternalLink width={18} />
</a> </Button>
); );
} }