feat(webui): add i18n support and locale switcher
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,10 +20,10 @@ interface ChatListProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function titleFor(s: ChatSummary): string {
|
||||
function titleFor(s: ChatSummary, fallbackTitle: string): string {
|
||||
const p = s.preview?.trim();
|
||||
if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p;
|
||||
return `Chat ${s.chatId.slice(0, 6)}`;
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
@@ -32,16 +33,19 @@ export function ChatList({
|
||||
onRequestDelete,
|
||||
loading,
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading && sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading…</div>
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
{t("chat.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground">
|
||||
No sessions yet.
|
||||
{t("chat.noSessions")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +55,10 @@ export function ChatList({
|
||||
<ul className="space-y-0.5 px-2 py-1">
|
||||
{sessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const title = titleFor(s);
|
||||
const title = titleFor(
|
||||
s,
|
||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
||||
);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<div
|
||||
@@ -80,7 +87,7 @@ export function ChatList({
|
||||
"focus-visible:opacity-100",
|
||||
active && "opacity-100",
|
||||
)}
|
||||
aria-label={`Chat actions for ${title}`}
|
||||
aria-label={t("chat.actions", { title })}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -95,7 +102,7 @@ export function ChatList({
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
@@ -15,6 +16,7 @@ interface CodeBlockProps {
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = () => {
|
||||
@@ -33,19 +35,21 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
return (
|
||||
<div className={cn("overflow-hidden rounded-lg", className)}>
|
||||
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
|
||||
<span className="lowercase">{language || "code"}</span>
|
||||
<span className="lowercase">
|
||||
{language || t("code.fallbackLanguage")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
|
||||
aria-label="Copy code"
|
||||
aria-label={t("code.copyAria")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type { ConnectionStatus } from "@/lib/types";
|
||||
|
||||
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
|
||||
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
|
||||
const COPY: Record<ConnectionStatus, { color: string }> = {
|
||||
idle: { color: "bg-card/40 text-muted-foreground" },
|
||||
connecting: {
|
||||
label: "Connecting…",
|
||||
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
open: {
|
||||
label: "Connected",
|
||||
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||
},
|
||||
reconnecting: {
|
||||
label: "Reconnecting…",
|
||||
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
},
|
||||
closed: {
|
||||
label: "Disconnected",
|
||||
color: "bg-card/40 text-muted-foreground",
|
||||
},
|
||||
error: {
|
||||
label: "Connection error",
|
||||
color: "bg-destructive/10 text-destructive",
|
||||
},
|
||||
};
|
||||
|
||||
export function ConnectionBadge() {
|
||||
const { t } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const [status, setStatus] = useState<ConnectionStatus>(client.status);
|
||||
|
||||
@@ -53,7 +50,7 @@ export function ConnectionBadge() {
|
||||
)}
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
{meta.label}
|
||||
{t(`connection.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DeleteConfirmProps {
|
||||
open: boolean;
|
||||
@@ -22,22 +23,27 @@ export function DeleteConfirm({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteConfirmProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete “{title}”?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteConfirm.title", { title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The session file will be removed from disk. This cannot be undone.
|
||||
{t("deleteConfirm.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={onCancel}>
|
||||
{t("deleteConfirm.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
{t("deleteConfirm.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
import {
|
||||
currentLocale,
|
||||
} from "@/i18n";
|
||||
import {
|
||||
localeOption,
|
||||
supportedLocales,
|
||||
type SupportedLocale,
|
||||
} from "@/i18n/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const locale = currentLocale();
|
||||
const selected = localeOption(locale);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t("sidebar.language.ariaLabel")}
|
||||
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="max-w-[7rem] truncate">{selected.nativeLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("sidebar.language.label")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
void setAppLanguage(value as SupportedLocale);
|
||||
}}
|
||||
>
|
||||
{supportedLocales.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.code} value={option.code}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span>{option.nativeLabel}</span>
|
||||
{option.nativeLabel !== option.label ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -63,9 +64,10 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
|
||||
/** Blinking cursor appended at the end of streaming text. */
|
||||
function StreamCursor() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label="streaming"
|
||||
aria-label={t("message.streaming")}
|
||||
className={cn(
|
||||
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
|
||||
"rounded-sm bg-foreground/70 animate-pulse",
|
||||
@@ -76,9 +78,10 @@ function StreamCursor() {
|
||||
|
||||
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||
function TypingDots() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label="Assistant is typing"
|
||||
aria-label={t("message.assistantTyping")}
|
||||
className="inline-flex items-center gap-1 py-1"
|
||||
>
|
||||
<Dot delay="0ms" />
|
||||
@@ -111,6 +114,7 @@ interface TraceGroupProps {
|
||||
* group down to a one-line summary so it never dominates the thread.
|
||||
*/
|
||||
function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const lines = message.traces ?? [message.content];
|
||||
const count = lines.length;
|
||||
const [open, setOpen] = useState(true);
|
||||
@@ -127,7 +131,9 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{count === 1 ? "Using a tool" : `Used ${count} tools`}
|
||||
{count === 1
|
||||
? t("message.toolSingle")
|
||||
: t("message.toolMany", { count })}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
@@ -20,13 +22,14 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
@@ -35,7 +38,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle theme"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
@@ -53,18 +56,18 @@ export function Sidebar(props: SidebarProps) {
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New chat
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Recent</span>
|
||||
<span>{t("sidebar.recent")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
onClick={props.onRefresh}
|
||||
aria-label="Refresh sessions"
|
||||
aria-label={t("sidebar.refreshSessions")}
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -79,8 +82,9 @@ export function Sidebar(props: SidebarProps) {
|
||||
/>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
|
||||
<ConnectionBadge />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -15,13 +16,16 @@ interface ThreadComposerProps {
|
||||
export function ThreadComposer({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder = "Type your message…",
|
||||
placeholder,
|
||||
modelLabel = null,
|
||||
variant = "thread",
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isHero = variant === "hero";
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t("thread.composer.placeholderThread");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
@@ -83,9 +87,9 @@ export function ThreadComposer({
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-label="Message input"
|
||||
aria-label={t("thread.composer.inputAria")}
|
||||
className={cn(
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
@@ -120,7 +124,7 @@ export function ThreadComposer({
|
||||
</span>
|
||||
) : null}
|
||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||
Enter to send · Shift+Enter for newline
|
||||
{t("thread.composer.sendHint")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="sm:hidden" aria-hidden />
|
||||
@@ -128,7 +132,7 @@ export function ThreadComposer({
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Send message"
|
||||
aria-label={t("thread.composer.send")}
|
||||
className={cn(
|
||||
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
|
||||
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -16,13 +17,14 @@ export function ThreadHeader({
|
||||
onGoHome,
|
||||
hideSidebarToggleOnDesktop = false,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="relative flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={t("thread.header.toggleSidebar")}
|
||||
onClick={onToggleSidebar}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
@@ -33,6 +34,7 @@ export function ThreadShell({
|
||||
onNewChat,
|
||||
hideSidebarToggleOnDesktop = false,
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading } = useSessionHistory(historyKey);
|
||||
@@ -105,7 +107,7 @@ export function ThreadShell({
|
||||
|
||||
const emptyState = loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading conversation…
|
||||
{t("thread.loadingConversation")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
@@ -120,7 +122,7 @@ export function ThreadShell({
|
||||
<span className="text-foreground/82">nanobot</span>
|
||||
</div>
|
||||
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
|
||||
Ask questions, continue local work, or start a new thread.
|
||||
{t("thread.empty.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -142,7 +144,11 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={send}
|
||||
disabled={!chatId}
|
||||
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
? t("thread.composer.placeholderHero")
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
/>
|
||||
@@ -150,7 +156,11 @@ export function ThreadShell({
|
||||
<ThreadComposer
|
||||
onSend={handleWelcomeSend}
|
||||
disabled={booting}
|
||||
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
|
||||
placeholder={
|
||||
booting
|
||||
? t("thread.composer.placeholderOpening")
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={toModelBadgeLabel(modelName)}
|
||||
variant="hero"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,6 +22,7 @@ export function ThreadViewport({
|
||||
composer,
|
||||
emptyState,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
@@ -104,7 +106,7 @@ export function ThreadViewport({
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
aria-label="Scroll to bottom"
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -58,12 +58,14 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -77,10 +79,12 @@ const SheetContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{showCloseButton ? (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user