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}
</p>
{prompt.prompt.length > 100 && (
<button
<Button
type="button"
variant="link"
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"}
</button>
</Button>
)}
</div>
<Button

View file

@ -97,17 +97,18 @@ function HotkeyRow({
<RotateCcw className="size-3" />
</Button>
)}
<button
<Button
ref={inputRef}
type="button"
variant="ghost"
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
className={
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"
: "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 border border-transparent bg-primary/5 px-0 outline-none ring-0 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 ? (
@ -115,7 +116,7 @@ function HotkeyRow({
) : (
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
)}
</button>
</Button>
</div>
</div>
);

View file

@ -277,22 +277,25 @@ export function PromptsContent() {
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
<Button
type="button"
variant="link"
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"}
</button>
</Button>
)}
</div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
<button
<Button
type="button"
variant="ghost"
size="icon"
title={prompt.is_public ? "Make private" : "Share with community"}
onClick={() => handleTogglePublic(prompt)}
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) ? (
<Spinner className="size-3.5" />
@ -301,7 +304,7 @@ export function PromptsContent() {
) : (
<Globe className="size-3.5" />
)}
</button>
</Button>
<Button
variant="ghost"
size="icon"

View file

@ -207,13 +207,14 @@ export default function DesktopPermissionsPage() {
<Button disabled className="text-sm h-9 min-w-[180px]">
Grant permissions to continue
</Button>
<button
<Button
type="button"
variant="link"
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
</button>
</Button>
</>
)}
</div>

View file

@ -74,10 +74,11 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
isAlreadyReverted && "opacity-70"
)}
>
<button
<Button
type="button"
variant="ghost"
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}
>
<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"
)}
/>
</button>
</Button>
{isExpanded && (
<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 { ImageIcon, ImageOffIcon } from "lucide-react";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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;
onError?: React.ReactEventHandler<HTMLImageElement>;
onLoad?: React.ReactEventHandler<HTMLImageElement>;
src?: string;
};
function ImagePreview({
@ -57,18 +64,17 @@ function ImagePreview({
src,
...props
}: ImagePreviewProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [loadedSrc, setLoadedSrc] = useState<string | undefined>(undefined);
const [errorSrc, setErrorSrc] = useState<string | undefined>(undefined);
const imageSrc = src ?? "";
const loaded = loadedSrc === src;
const error = errorSrc === src;
const loaded = imageSrc !== "" && loadedSrc === imageSrc;
const error = imageSrc === "" || errorSrc === imageSrc;
useEffect(() => {
if (typeof src === "string" && imgRef.current?.complete && imgRef.current.naturalWidth > 0) {
setLoadedSrc(src);
}
}, [src]);
setLoadedSrc((current) => (current === imageSrc ? current : undefined));
setErrorSrc((current) => (current === imageSrc ? current : undefined));
}, [imageSrc]);
return (
<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" />
</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
fill
src={src || ""}
src={imageSrc}
alt={alt}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 60vw"
className={cn("block object-contain", !loaded && "invisible", className)}
onLoad={() => {
if (typeof src === "string") setLoadedSrc(src);
onLoad?.();
onLoad={(event) => {
setLoadedSrc(imageSrc);
onLoad?.(event);
}}
onError={() => {
if (typeof src === "string") setErrorSrc(src);
onError?.();
onError={(event) => {
setErrorSrc(imageSrc);
onError?.(event);
}}
unoptimized={false}
unoptimized={isDataOrBlobUrl(imageSrc)}
{...props}
/>
)}
@ -196,44 +169,26 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
return (
<>
<button
<Button
type="button"
variant="ghost"
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"
>
{children}
</button>
</Button>
{isMounted &&
isOpen &&
createPortal(
<button
<Button
type="button"
variant="ghost"
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}
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
data-slot="image-zoom-content"
fill
@ -245,10 +200,9 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
e.stopPropagation();
handleClose();
}}
unoptimized={false}
unoptimized={isDataOrBlobUrl(src)}
/>
)}
</button>,
</Button>,
document.body
)}
</>

View file

@ -13,6 +13,7 @@ import {
Presentation,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
@ -243,9 +244,11 @@ export function GoogleDriveFolderTree({
)}
>
{isFolder ? (
<button
<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) => {
e.stopPropagation();
toggleFolder(item);
@ -259,7 +262,7 @@ export function GoogleDriveFolderTree({
) : (
<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" />
)}
@ -290,13 +293,14 @@ export function GoogleDriveFolderTree({
</div>
{isFolder ? (
<button
<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)}
>
{item.name}
</button>
</Button>
) : (
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
{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"
/>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" />
<button
<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")}
>
My Drive
</button>
</Button>
</div>
</div>

View file

@ -241,9 +241,11 @@ export function DocumentsFilters({
aria-label={t("filter_placeholder")}
/>
{Boolean(searchValue) && (
<button
<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"
onClick={() => {
onSearch("");
@ -251,7 +253,7 @@ export function DocumentsFilters({
}}
>
<X size={14} strokeWidth={2} aria-hidden="true" />
</button>
</Button>
)}
</div>

View file

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

View file

@ -177,12 +177,13 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
<div className="flex-1 overflow-y-auto p-2">
<div className="flex flex-col gap-0.5">
{versions.map((v) => (
<button
<Button
key={v.version_number}
type="button"
variant="ghost"
onClick={() => handleSelectVersion(v.version_number)}
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
? "bg-accent 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>}
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</Button>
))}
</div>
</div>

View file

@ -11,20 +11,16 @@ import {
} from "@/lib/citations/citation-parser";
/**
* Plate inline-void node modeling a single `[citation:...]` reference.
*
* Modeled after the existing `MentionPlugin` pattern in
* `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.
* Plate inline-void node for one `[citation:...]` reference.
* Inline voids keep the citation chip atomic while preserving caret behavior
* around the surrounding text.
*/
export type CitationElementNode = {
type: "citation";
kind: "chunk" | "doc" | "url";
chunkId?: number;
url?: string;
/** Original `[citation:...]` substring for traceability/debugging. */
/** Original literal token that produced this citation node. */
rawText: string;
children: [{ text: "" }];
};
@ -62,17 +58,14 @@ const CitationPlugin = createPlatePlugin({
},
});
/** Plugin kit shape used elsewhere in the editor. */
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
// generic Element / Text type predicates directly because `Descendant` is a
// constrained union and our predicates would over-narrow. Casting through
// these row types keeps the walker readable without fighting the types.
// Local structural shapes keep the recursive walker readable without forcing
// Plate's broad Descendant union into narrower generic predicates.
type SlateText = { text: string } & 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
* into. Each rationale documented in the citation plan:
* - `KEYS.codeBlock` / `code_line` Plate's schema rejects inline elements
* inside code containers; the user expects literal text inside code.
* - `KEYS.link` `<button>` inside `<a>` is invalid HTML and the link
* swallows the citation click. Mirrors the `<a>` skip in
* `MarkdownViewer`.
* Subtrees that should keep citation tokens as text:
* - Code nodes preserve source text and reject inline void children.
* - Link nodes already render as anchors; citation chips are interactive
* shadcn Button-based controls, so injecting them would nest interactions.
*/
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
* (bold/italic/etc.) on the surrounding text fragments after we split.
* Preserve text marks such as bold and italic when splitting around citations.
*/
function copyMarks(textNode: SlateText): Record<string, unknown> {
const { text: _text, ...marks } = textNode;
@ -131,9 +120,7 @@ function makeCitationElement(
}
/**
* Re-extract the raw `[citation:...]` substrings that produced each parsed
* segment, in source order. Lets us preserve the original literal for
* `rawText` on the inline-void element.
* Keep each original citation token on the generated node for diagnostics.
*/
function extractRawCitationMatches(text: string): string[] {
const matches: string[] = [];
@ -159,9 +146,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
let pendingText: string | null = null;
const flushText = () => {
// Slate inline-void adjacency: emit an empty text node (with copied
// marks) when the citation appears at the very start/end of the text
// node so neighbours of the void always have a text sibling.
// Inline voids need text siblings, even at text boundaries.
out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant);
pendingText = null;
};
@ -174,8 +159,7 @@ function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[
const raw = rawMatches[citationIdx] ?? "";
out.push(makeCitationElement(raw, segment) as unknown as Descendant);
citationIdx += 1;
// Always reset pendingText so the next loop iteration emits a
// trailing empty text node if no further plain text follows.
// Ensure a trailing text sibling if the citation ends the node.
pendingText = "";
}
}
@ -206,12 +190,9 @@ function transformChildren(children: Descendant[], urlMap: CitationUrlMap): Desc
}
/**
* Walk a deserialized Slate value and replace every `[citation:...]`
* substring with a `citation` inline-void element. URL placeholders
* created by `preprocessCitationMarkdown` are resolved through `urlMap`.
*
* Subtrees of `code_block`, `code_line`, and `link` are returned as-is
* see `SKIP_SUBTREE_TYPES` above.
* Replace citation tokens in a deserialized Slate tree with citation inline
* void nodes. URL placeholders from `preprocessCitationMarkdown` are resolved
* through `urlMap`; skipped subtrees are returned unchanged.
*/
export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] {
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 { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
@ -185,18 +186,19 @@ export const FreeComposer: FC = () => {
/>
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={handleUploadClick}
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",
hasUploadedDoc && "text-primary"
)}
>
<Paperclip className="size-3.5" />
{hasUploadedDoc ? "1/1" : "Upload"}
</button>
</Button>
</TooltipTrigger>
<TooltipContent>
{hasUploadedDoc

View file

@ -3,6 +3,7 @@
import { OctagonAlert, Orbit, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface QuotaWarningBannerProps {
@ -71,13 +72,15 @@ export function QuotaWarningBanner({
</Link>{" "}
for $5 of premium credit.
</p>
<button
<Button
type="button"
variant="ghost"
size="icon"
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" />
</button>
</Button>
</div>
</div>
);

View file

@ -2,6 +2,7 @@
import { IconMessageCircleQuestion } from "@tabler/icons-react";
import Link from "next/link";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function CTAHomepage() {
@ -22,15 +23,16 @@ export function CTAHomepage() {
</p>
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
<Link href="/contact">
<button
type="button"
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]"
<Button
asChild
variant="ghost"
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>
<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>
</Button>
</div>
</div>
{/* <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 React, { memo, useCallback, useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@ -184,24 +185,26 @@ function GetStartedButton() {
if (isGoogleAuth) {
return (
<button
<Button
type="button"
variant="ghost"
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" />
<span>Continue with Google</span>
</button>
</Button>
);
}
return (
<Link
href="/login"
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"
<Button
asChild
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>
<Link href="/login">Get Started</Link>
</Button>
);
}
@ -212,35 +215,40 @@ function DownloadButton() {
if (!primary) {
return (
<a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
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"
<Button
asChild
variant="ghost"
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"
>
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer">
<Download className="size-4" />
Download for {os}
</a>
</Button>
);
}
return (
<div className="flex h-14 w-full items-stretch sm:w-auto">
<a
href={primary.url}
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"
<Button
asChild
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 for {os}
</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<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" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{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">
{TAB_ITEMS.map((item, index) => (
<React.Fragment key={item.title}>
<button
<Button
type="button"
variant="ghost"
onClick={() => setSelectedIndex(index)}
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 &&
!item.featured &&
"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>
</Tooltip>
)}
</button>
</Button>
{index !== TAB_ITEMS.length - 1 && (
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
)}
@ -354,13 +363,14 @@ const BrowserWindow = () => {
</p>
</div>
</div>
<button
<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}
>
<TabVideo src={selectedItem.src} />
</button>
<TabVideo key={selectedItem.src} src={selectedItem.src} />
</Button>
</motion.div>
</AnimatePresence>
</div>
@ -385,7 +395,7 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
if (!video) return;
video.currentTime = 0;
video.play().catch(() => {});
}, [src]);
}, []);
const handleCanPlay = useCallback(() => {
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 { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface NavItem {
@ -99,7 +100,7 @@ const DesktopNav = ({ navItems, isScrolled, scrolledBgClassName }: DesktopNavPro
onMouseEnter={() => setHovered(idx)}
onMouseLeave={() => setHovered(null)}
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
key={`link=${idx}`}
key={navItem.link}
href={navItem.link}
>
{hovered === idx && (
@ -179,10 +180,12 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
<Logo className="h-8 w-8 rounded-md" disableLink />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</Link>
<button
<Button
type="button"
variant="ghost"
size="icon"
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"}
>
{open ? (
@ -190,7 +193,7 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
) : (
<IconMenu2 className="h-6 w-6 text-black dark:text-white" />
)}
</button>
</Button>
</div>
<AnimatePresence>
@ -202,9 +205,9 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: MobileNavProps
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"
>
{navItems.map((navItem: NavItem, idx: number) => (
{navItems.map((navItem: NavItem) => (
<Link
key={`link=${idx}`}
key={navItem.link}
href={navItem.link}
className="relative text-neutral-600 dark:text-neutral-300"
>

View file

@ -3,6 +3,7 @@
import { Settings, Trash2, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@ -120,11 +121,13 @@ export function SearchSpaceAvatar({
);
const avatarButton = (
<button
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClick}
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",
sizeClasses,
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")} />
</span>
)}
</button>
</Button>
);
const menuItems = (

View file

@ -347,8 +347,9 @@ export function AllSharedChatsSidebarContent({
return (
<div key={thread.id} className="group/item relative w-full">
{isMobile ? (
<button
<Button
type="button"
variant="ghost"
onClick={() => {
if (wasLongPress()) return;
handleThreadClick(thread.id);
@ -361,7 +362,7 @@ export function AllSharedChatsSidebarContent({
onTouchMove={longPressHandlers.onTouchMove}
disabled={isBusy}
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",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
@ -369,16 +370,17 @@ export function AllSharedChatsSidebarContent({
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
) : (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
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",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground",
@ -386,7 +388,7 @@ export function AllSharedChatsSidebarContent({
)}
>
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>

View file

@ -56,19 +56,20 @@ export function ChatListItem({
return (
<div className="group/item relative w-full">
<button
<Button
type="button"
variant="ghost"
onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
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",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "bg-accent text-accent-foreground"
)}
>
<span className="truncate">{animatedName}</span>
</button>
</Button>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div

View file

@ -575,14 +575,15 @@ export function InboxSidebarContent({
{t("filter") || "Filter"}
</p>
<div className="space-y-1">
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("all");
setFilterDrawerOpen(false);
}}
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"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -593,15 +594,16 @@ export function InboxSidebarContent({
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("unread");
setFilterDrawerOpen(false);
}}
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"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -612,16 +614,17 @@ export function InboxSidebarContent({
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</Button>
{activeTab === "status" && (
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setActiveFilter("errors");
setFilterDrawerOpen(false);
}}
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"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -632,7 +635,7 @@ export function InboxSidebarContent({
<span>{t("errors_only") || "Errors only"}</span>
</span>
{activeFilter === "errors" && <Check className="h-4 w-4" />}
</button>
</Button>
)}
</div>
</div>
@ -642,14 +645,15 @@ export function InboxSidebarContent({
{t("sources") || "Sources"}
</p>
<div className="space-y-1">
<button
<Button
type="button"
variant="ghost"
onClick={() => {
setSelectedSource(null);
setFilterDrawerOpen(false);
}}
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
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -660,17 +664,18 @@ export function InboxSidebarContent({
<span>{t("all_sources") || "All sources"}</span>
</span>
{selectedSource === null && <Check className="h-4 w-4" />}
</button>
</Button>
{statusSourceOptions.map((source) => (
<button
<Button
key={source.key}
type="button"
variant="ghost"
onClick={() => {
setSelectedSource(source.key);
setFilterDrawerOpen(false);
}}
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
? "bg-primary/10 text-primary"
: "hover:bg-muted"
@ -681,7 +686,7 @@ export function InboxSidebarContent({
<span>{source.displayName}</span>
</span>
{selectedSource === source.key && <Check className="h-4 w-4" />}
</button>
</Button>
))}
</div>
</div>
@ -922,11 +927,12 @@ export function InboxSidebarContent({
{activeTab === "status" ? (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
onClick={() => handleItemClick(item)}
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="flex-1 min-w-0 overflow-hidden">
@ -942,7 +948,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
@ -952,11 +958,12 @@ export function InboxSidebarContent({
</TooltipContent>
</Tooltip>
) : (
<button
<Button
type="button"
variant="ghost"
onClick={() => handleItemClick(item)}
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="flex-1 min-w-0 overflow-hidden">
@ -972,7 +979,7 @@ export function InboxSidebarContent({
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</Button>
)}
<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 { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
@ -204,13 +205,14 @@ export function Sidebar({
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
action={
onViewAllSharedChats ? (
<button
<Button
type="button"
variant="ghost"
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")}
</button>
</Button>
) : undefined
}
>
@ -260,13 +262,14 @@ export function Sidebar({
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
action={
onViewAllPrivateChats ? (
<button
<Button
type="button"
variant="ghost"
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")}
</button>
</Button>
) : undefined
}
>

View file

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

View file

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

View file

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

View file

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

View file

@ -115,13 +115,15 @@ function buildFolderTree(entries: FolderEntry[]): FolderTreeNode[] {
function flattenTree(
nodes: FolderTreeNode[],
depth = 0
): { name: string; isFolder: boolean; depth: number; size?: number }[] {
const items: { name: string; isFolder: boolean; depth: number; size?: number }[] = [];
depth = 0,
parentPath = ""
): { 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) {
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) {
items.push(...flattenTree(node.children, depth + 1));
items.push(...flattenTree(node.children, depth + 1, path));
}
}
return items;
@ -537,37 +539,39 @@ export function DocumentUploadTab({
isElectron ? (
<div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div>
) : (
<button
<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()}
>
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
role="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click();
}
}}
<div className="flex w-full flex-col items-center gap-4 bg-transparent px-4 py-12 select-none">
{isElectron ? (
<div className="flex w-full flex-col items-center gap-4">
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">{t("select_files_or_folder")}</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
</div>
) : (
<Button
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" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-base font-medium">{t("tap_select_files_or_folder")}</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
</Button>
)}
<fieldset
className="w-full mt-1 border-none p-0 m-0"
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">
{folderUpload
? folderTreeItems.map((item, i) => (
? folderTreeItems.map((item) => (
<div
key={`${item.depth}-${i}-${item.name}`}
key={item.path}
className="flex items-center gap-1.5 py-0.5 px-2"
style={{ paddingLeft: `${item.depth * 16 + 8}px` }}
>
@ -726,10 +730,11 @@ export function DocumentUploadTab({
<div className="space-y-1.5">
<p className="font-medium text-sm px-1">{t("processing_mode")}</p>
<div className="grid grid-cols-2 gap-2">
<button
<Button
type="button"
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"
? "border-primary bg-primary/5"
: "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="text-xs text-muted-foreground">{t("basic_mode_desc")}</p>
</div>
</button>
<button
</Button>
<Button
type="button"
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"
? "border-amber-500 bg-amber-500/5"
: "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="text-xs text-muted-foreground">{t("premium_mode_desc")}</p>
</div>
</button>
</Button>
</div>
</div>

View file

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