mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 04:15:42 +08:00
fix(ui): remove mobile bottom nav, unify processing indicator, and improve tooltip behavior on mobile (#632)
* fix: update tooltip component * fix: remove the mobile navigation component In addition, - the sidebar is also updated to take full space - the terminal shortcuts in shell are updated to not interfere with the shell content. * fix: remove mobile nav component * fix: remove "Thinking..." indicator In addition, the claude status component has been restyled to be more compact and less obtrusive. - The type and prop arguments for ChatMessagesPane have been updated to remove the isLoading prop, which was only used to control the display of the AssistantThinkingIndicator. * fix: show elapsed time only when loading --------- Co-authored-by: Haileyesus <something@gmail.com> Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import { useWebSocket } from '../../contexts/WebSocketContext';
|
|||||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||||
import MobileNav from './MobileNav';
|
|
||||||
|
|
||||||
export default function AppContent() {
|
export default function AppContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -33,7 +32,6 @@ export default function AppContent() {
|
|||||||
activeTab,
|
activeTab,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
isLoadingProjects,
|
isLoadingProjects,
|
||||||
isInputFocused,
|
|
||||||
externalMessageUpdate,
|
externalMessageUpdate,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
@@ -159,7 +157,7 @@ export default function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<MainContent
|
<MainContent
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
selectedSession={selectedSession}
|
selectedSession={selectedSession}
|
||||||
@@ -184,14 +182,6 @@ export default function AppContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
|
||||||
<MobileNav
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
isInputFocused={isInputFocused}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
MessageSquare,
|
|
||||||
Folder,
|
|
||||||
Terminal,
|
|
||||||
GitBranch,
|
|
||||||
ClipboardCheck,
|
|
||||||
Ellipsis,
|
|
||||||
Puzzle,
|
|
||||||
Box,
|
|
||||||
Database,
|
|
||||||
Globe,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
BarChart3,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
|
|
||||||
import { usePlugins } from '../../contexts/PluginsContext';
|
|
||||||
import { AppTab } from '../../types/app';
|
|
||||||
|
|
||||||
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
|
|
||||||
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
|
|
||||||
};
|
|
||||||
|
|
||||||
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
|
|
||||||
type CoreNavItem = {
|
|
||||||
id: CoreTabId;
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MobileNavProps = {
|
|
||||||
activeTab: AppTab;
|
|
||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
|
||||||
isInputFocused: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
|
||||||
const { t } = useTranslation(['common', 'settings']);
|
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
|
||||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
|
||||||
const { plugins } = usePlugins();
|
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
|
||||||
const moreRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const enabledPlugins = plugins.filter((p) => p.enabled);
|
|
||||||
const hasPlugins = enabledPlugins.length > 0;
|
|
||||||
const isPluginActive = activeTab.startsWith('plugin:');
|
|
||||||
|
|
||||||
// Close the menu on outside tap
|
|
||||||
useEffect(() => {
|
|
||||||
if (!moreOpen) return;
|
|
||||||
const handleTap = (e: PointerEvent) => {
|
|
||||||
const target = e.target;
|
|
||||||
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
|
|
||||||
setMoreOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('pointerdown', handleTap);
|
|
||||||
return () => document.removeEventListener('pointerdown', handleTap);
|
|
||||||
}, [moreOpen]);
|
|
||||||
|
|
||||||
// Close menu when a plugin tab is selected
|
|
||||||
const selectPlugin = (name: string) => {
|
|
||||||
const pluginTab = `plugin:${name}` as AppTab;
|
|
||||||
setActiveTab(pluginTab);
|
|
||||||
setMoreOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseCoreItems: CoreNavItem[] = [
|
|
||||||
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
|
|
||||||
{ id: 'shell', icon: Terminal, label: 'Shell' },
|
|
||||||
{ id: 'files', icon: Folder, label: 'Files' },
|
|
||||||
{ id: 'git', icon: GitBranch, label: 'Git' },
|
|
||||||
];
|
|
||||||
const coreItems: CoreNavItem[] = shouldShowTasksTab
|
|
||||||
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
|
|
||||||
: baseCoreItems;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed bottom-0 left-0 right-0 z-50 transform px-3 pb-[max(8px,env(safe-area-inset-bottom))] transition-transform duration-300 ease-in-out ${isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
|
||||||
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
|
|
||||||
{coreItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = activeTab === item.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActiveTab(item.id)}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveTab(item.id);
|
|
||||||
}}
|
|
||||||
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
aria-label={item.label}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{isActive && (
|
|
||||||
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
|
|
||||||
)}
|
|
||||||
<Icon
|
|
||||||
className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
|
|
||||||
strokeWidth={isActive ? 2.4 : 1.8}
|
|
||||||
/>
|
|
||||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* "More" button — only shown when there are enabled plugins */}
|
|
||||||
{hasPlugins && (
|
|
||||||
<div ref={moreRef} className="relative flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setMoreOpen((v) => !v)}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setMoreOpen((v) => !v);
|
|
||||||
}}
|
|
||||||
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
aria-label="More plugins"
|
|
||||||
aria-expanded={moreOpen}
|
|
||||||
>
|
|
||||||
{(isPluginActive && !moreOpen) && (
|
|
||||||
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
|
|
||||||
)}
|
|
||||||
<Ellipsis
|
|
||||||
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
|
|
||||||
strokeWidth={isPluginActive ? 2.4 : 1.8}
|
|
||||||
/>
|
|
||||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
|
|
||||||
{t('settings:pluginSettings.morePlugins')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Popover menu */}
|
|
||||||
{moreOpen && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
|
|
||||||
{enabledPlugins.map((p) => {
|
|
||||||
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
|
|
||||||
const isActive = activeTab === `plugin:${p.name}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p.name}
|
|
||||||
onClick={() => selectPlugin(p.name)}
|
|
||||||
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
|
|
||||||
? 'bg-primary/8 text-primary'
|
|
||||||
: 'text-foreground hover:bg-muted/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
|
|
||||||
<span className="truncate">{p.displayName}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -338,7 +338,6 @@ function ChatInterface({
|
|||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { SessionProvider } from '../../../../types/app';
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
|
||||||
|
|
||||||
type AssistantThinkingIndicatorProps = {
|
|
||||||
selectedProvider: SessionProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
|
|
||||||
return (
|
|
||||||
<div className="chat-message assistant">
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="mb-2 flex items-center space-x-3">
|
|
||||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-transparent p-1 text-sm text-white">
|
|
||||||
<SessionProviderLogo provider={selectedProvider} className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full pl-3 text-sm text-gray-500 dark:text-gray-400 sm:pl-0">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<div className="animate-pulse">.</div>
|
|
||||||
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
<span className="ml-2">Thinking...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import type { Project, ProjectSession, SessionProvider } from '../../../../types
|
|||||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||||
import MessageComponent from './MessageComponent';
|
import MessageComponent from './MessageComponent';
|
||||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||||
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
|
|
||||||
|
|
||||||
interface ChatMessagesPaneProps {
|
interface ChatMessagesPaneProps {
|
||||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||||
@@ -51,7 +50,6 @@ interface ChatMessagesPaneProps {
|
|||||||
showRawParameters?: boolean;
|
showRawParameters?: boolean;
|
||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject: Project;
|
selectedProject: Project;
|
||||||
isLoading: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessagesPane({
|
export default function ChatMessagesPane({
|
||||||
@@ -97,7 +95,6 @@ export default function ChatMessagesPane({
|
|||||||
showRawParameters,
|
showRawParameters,
|
||||||
showThinking,
|
showThinking,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
isLoading,
|
|
||||||
}: ChatMessagesPaneProps) {
|
}: ChatMessagesPaneProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||||
@@ -261,8 +258,6 @@ export default function ChatMessagesPane({
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const ACTION_KEYS = [
|
|||||||
'claudeStatus.actions.reasoning',
|
'claudeStatus.actions.reasoning',
|
||||||
];
|
];
|
||||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||||
const ANIMATION_STEPS = 40;
|
|
||||||
|
|
||||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||||
claude: 'messageTypes.claude',
|
claude: 'messageTypes.claude',
|
||||||
@@ -32,19 +31,10 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
|||||||
gemini: 'messageTypes.gemini',
|
gemini: 'messageTypes.gemini',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {
|
function formatElapsedTime(totalSeconds: number) {
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const mins = Math.floor(totalSeconds / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const secs = totalSeconds % 60;
|
||||||
|
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
||||||
if (minutes < 1) {
|
|
||||||
return t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return t('claudeStatus.elapsed.minutesSeconds', {
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
defaultValue: '{{minutes}}m {{seconds}}s',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaudeStatus({
|
export default function ClaudeStatus({
|
||||||
@@ -55,141 +45,83 @@ export default function ClaudeStatus({
|
|||||||
}: ClaudeStatusProps) {
|
}: ClaudeStatusProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
const [animationPhase, setAnimationPhase] = useState(0);
|
const [dots, setDots] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
setElapsedTime(0);
|
setElapsedTime(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const timer = setInterval(() => {
|
||||||
const timer = window.setInterval(() => {
|
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
setElapsedTime(elapsed);
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
const dotTimer = setInterval(() => {
|
||||||
return () => window.clearInterval(timer);
|
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearInterval(dotTimer);
|
||||||
|
};
|
||||||
}, [isLoading]);
|
}, [isLoading]);
|
||||||
|
|
||||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
if (!isLoading && !status) return null;
|
||||||
if (!isLoading && !status) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionWords = ACTION_KEYS.map((key, index) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[index] }));
|
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
||||||
const statusText = status?.text || actionWords[actionIndex];
|
|
||||||
const cleanStatusText = statusText.replace(/[.]+$/, '');
|
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
||||||
const canInterrupt = isLoading && status?.can_interrupt !== false;
|
|
||||||
const providerLabelKey = PROVIDER_LABEL_KEYS[provider];
|
|
||||||
const providerLabel = providerLabelKey
|
|
||||||
? t(providerLabelKey)
|
|
||||||
: t('claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
|
||||||
const animatedDots = '.'.repeat((animationPhase % 3) + 1);
|
|
||||||
const elapsedLabel =
|
|
||||||
elapsedTime > 0
|
|
||||||
? t('claudeStatus.elapsed.label', {
|
|
||||||
time: formatElapsedTime(elapsedTime, t),
|
|
||||||
defaultValue: '{{time}} elapsed',
|
|
||||||
})
|
|
||||||
: t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6">
|
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
||||||
<div className="relative mx-auto max-w-4xl overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md">
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" />
|
|
||||||
|
|
||||||
<div className="relative px-3 py-3 sm:px-4 sm:py-3.5">
|
{/* Left Side: Identity & Status */}
|
||||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="flex min-w-0 items-start gap-3" role="status" aria-live="polite">
|
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
||||||
<div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-primary/25 bg-primary/10">
|
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||||||
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
{isLoading && (
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
|
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
||||||
{isLoading && (
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/70" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
|
||||||
isLoading ? 'bg-emerald-400' : 'bg-amber-400',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="mb-0.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
|
||||||
<span>{providerLabel}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-2 py-0.5 text-[9px] tracking-[0.14em]',
|
|
||||||
isLoading
|
|
||||||
? 'bg-emerald-500/15 text-emerald-500 dark:text-emerald-400'
|
|
||||||
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? t('claudeStatus.state.live', { defaultValue: 'Live' })
|
|
||||||
: t('claudeStatus.state.paused', { defaultValue: 'Paused' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="truncate text-sm font-semibold text-foreground sm:text-[15px]">
|
|
||||||
{cleanStatusText}
|
|
||||||
{isLoading && (
|
|
||||||
<span aria-hidden="true" className="text-primary">
|
|
||||||
{animatedDots}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="-ml-2 inline-flex items-center rounded-full border border-border/70 bg-background/60 px-2 py-0.5"
|
|
||||||
>
|
|
||||||
{elapsedLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canInterrupt && onAbort && (
|
|
||||||
<div className="w-full sm:w-auto sm:text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAbort}
|
|
||||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 active:opacity-90 sm:w-auto"
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
<span>{t('claudeStatus.controls.stopGeneration', { defaultValue: 'Stop Generation' })}</span>
|
|
||||||
<span className="rounded-md bg-black/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-destructive-foreground/95">
|
|
||||||
Esc
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="mt-1 hidden text-[11px] text-muted-foreground sm:block">
|
|
||||||
{t('claudeStatus.controls.pressEscToStop', { defaultValue: 'Press Esc anytime to stop' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
|
||||||
|
{providerLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||||
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
|
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Metrics & Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isLoading && status?.can_interrupt !== false && onAbort && (
|
||||||
|
<>
|
||||||
|
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
|
||||||
|
{formatElapsedTime(elapsedTime)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAbort}
|
||||||
|
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 6h12v12H6z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">STOP</span>
|
||||||
|
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export default function BranchesView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-1 flex-col overflow-hidden ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Create branch button */}
|
{/* Create branch button */}
|
||||||
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
|
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export default function ChangesView({
|
|||||||
|
|
||||||
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
|
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
|
||||||
|
|
||||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function HistoryView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
|
|||||||
|
|
||||||
type QuickSettingsContentProps = {
|
type QuickSettingsContentProps = {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
isMobile: boolean;
|
|
||||||
preferences: QuickSettingsPreferences;
|
preferences: QuickSettingsPreferences;
|
||||||
onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;
|
onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QuickSettingsContent({
|
export default function QuickSettingsContent({
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
isMobile,
|
|
||||||
preferences,
|
preferences,
|
||||||
onPreferenceChange,
|
onPreferenceChange,
|
||||||
}: QuickSettingsContentProps) {
|
}: QuickSettingsContentProps) {
|
||||||
@@ -45,7 +43,7 @@ export default function QuickSettingsContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4 ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
|
||||||
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
||||||
<div className={SETTING_ROW_CLASS}>
|
<div className={SETTING_ROW_CLASS}>
|
||||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function QuickSettingsPanelView() {
|
|||||||
<QuickSettingsPanelHeader />
|
<QuickSettingsPanelHeader />
|
||||||
<QuickSettingsContent
|
<QuickSettingsContent
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
isMobile={isMobile}
|
|
||||||
preferences={quickSettingsPreferences}
|
preferences={quickSettingsPreferences}
|
||||||
onPreferenceChange={handlePreferenceChange}
|
onPreferenceChange={handlePreferenceChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function TerminalShortcutsPanel({
|
|||||||
wsRef,
|
wsRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
isConnected,
|
isConnected,
|
||||||
bottomOffset = 'bottom-14',
|
bottomOffset = 'bottom-0',
|
||||||
}: TerminalShortcutsPanelProps) {
|
}: TerminalShortcutsPanelProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const [ctrlActive, setCtrlActive] = useState(false);
|
const [ctrlActive, setCtrlActive] = useState(false);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function SidebarFooter({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile settings */}
|
{/* Mobile settings */}
|
||||||
<div className="px-3 pb-20 pt-2 md:hidden">
|
<div className="px-3 pb-3 pt-2 md:hidden">
|
||||||
<button
|
<button
|
||||||
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
className="flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]"
|
||||||
onClick={onShowSettings}
|
onClick={onShowSettings}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||||
@@ -11,21 +12,6 @@ type TooltipProps = {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPositionClasses(position: TooltipPosition): string {
|
|
||||||
switch (position) {
|
|
||||||
case 'top':
|
|
||||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
|
||||||
case 'bottom':
|
|
||||||
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
|
|
||||||
case 'left':
|
|
||||||
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
|
|
||||||
case 'right':
|
|
||||||
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
|
|
||||||
default:
|
|
||||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArrowClasses(position: TooltipPosition): string {
|
function getArrowClasses(position: TooltipPosition): string {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top':
|
case 'top':
|
||||||
@@ -46,11 +32,54 @@ function Tooltip({
|
|||||||
content,
|
content,
|
||||||
position = 'top',
|
position = 'top',
|
||||||
className = '',
|
className = '',
|
||||||
delay = 500,
|
delay = 350,
|
||||||
}: TooltipProps) {
|
}: TooltipProps) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
// Store the timer id without forcing re-renders while hovering.
|
// Store the timer id without forcing re-renders while hovering.
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const longPressTriggeredRef = useRef(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties | null>(null);
|
||||||
|
|
||||||
|
const updateTooltipPosition = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const spacing = 8;
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 9999,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate tooltip position based on the specified position prop.
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom':
|
||||||
|
style.left = rect.left + rect.width / 2;
|
||||||
|
style.top = rect.bottom + spacing;
|
||||||
|
style.transform = 'translateX(-50%)';
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
style.left = rect.left - spacing;
|
||||||
|
style.top = rect.top + rect.height / 2;
|
||||||
|
style.transform = 'translate(-100%, -50%)';
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
style.left = rect.right + spacing;
|
||||||
|
style.top = rect.top + rect.height / 2;
|
||||||
|
style.transform = 'translateY(-50%)';
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
default:
|
||||||
|
style.left = rect.left + rect.width / 2;
|
||||||
|
style.top = rect.top - spacing;
|
||||||
|
style.transform = 'translate(-50%, -100%)';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTooltipStyle(style);
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
const clearTooltipTimer = () => {
|
const clearTooltipTimer = () => {
|
||||||
if (timeoutRef.current !== null) {
|
if (timeoutRef.current !== null) {
|
||||||
@@ -71,6 +100,23 @@ function Tooltip({
|
|||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = () => {
|
||||||
|
clearTooltipTimer();
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
longPressTriggeredRef.current = true;
|
||||||
|
setIsVisible(true);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
clearTooltipTimer();
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Avoid delayed updates after unmount.
|
// Avoid delayed updates after unmount.
|
||||||
return () => {
|
return () => {
|
||||||
@@ -78,26 +124,73 @@ function Tooltip({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && containerRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsVisible(false);
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
setTooltipStyle(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(updateTooltipPosition);
|
||||||
|
const handleViewportChange = () => updateTooltipPosition();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [isVisible, updateTooltipPosition]);
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative inline-block"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchEnd}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{isVisible && (
|
{isVisible && typeof document !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={tooltipStyle || { position: 'fixed', top: '-9999px', left: '-9999px', opacity: 0 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
|
'px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
|
||||||
'animate-in fade-in-0 zoom-in-95 duration-200',
|
'animate-in fade-in-0 zoom-in-95 duration-200',
|
||||||
getPositionClasses(position),
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
{/* Arrow */}
|
{/* Arrow */}
|
||||||
<div className={cn('absolute w-0 h-0 border-4 border-transparent', getArrowClasses(position))} />
|
<div className={cn('absolute w-0 h-0 border-4 border-transparent', getArrowClasses(position))} />
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user