mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-12 00:21:30 +00: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 { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
import MobileNav from './MobileNav';
|
||||
|
||||
export default function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
@@ -33,7 +32,6 @@ export default function AppContent() {
|
||||
activeTab,
|
||||
sidebarOpen,
|
||||
isLoadingProjects,
|
||||
isInputFocused,
|
||||
externalMessageUpdate,
|
||||
setActiveTab,
|
||||
setSidebarOpen,
|
||||
@@ -159,7 +157,7 @@ export default function AppContent() {
|
||||
</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
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
@@ -184,14 +182,6 @@ export default function AppContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isInputFocused={isInputFocused}
|
||||
/>
|
||||
)}
|
||||
|
||||
</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}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<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 MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
@@ -51,7 +50,6 @@ interface ChatMessagesPaneProps {
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
selectedProject: Project;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function ChatMessagesPane({
|
||||
@@ -97,7 +95,6 @@ export default function ChatMessagesPane({
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
isLoading,
|
||||
}: ChatMessagesPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||
@@ -261,8 +258,6 @@ export default function ChatMessagesPane({
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ const ACTION_KEYS = [
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const ANIMATION_STEPS = 40;
|
||||
|
||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
claude: 'messageTypes.claude',
|
||||
@@ -32,19 +31,10 @@ const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
gemini: 'messageTypes.gemini',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
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',
|
||||
});
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
export default function ClaudeStatus({
|
||||
@@ -55,143 +45,85 @@ export default function ClaudeStatus({
|
||||
}: ClaudeStatusProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
const timer = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);
|
||||
const dotTimer = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(dotTimer);
|
||||
};
|
||||
}, [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 actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const cleanStatusText = statusText.replace(/[.]+$/, '');
|
||||
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' });
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
||||
|
||||
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
||||
|
||||
return (
|
||||
<div className="animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6">
|
||||
<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="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="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
||||
<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="relative px-3 py-3 sm:px-4 sm:py-3.5">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3" role="status" aria-live="polite">
|
||||
<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-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
|
||||
{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>
|
||||
{/* Left Side: Identity & Status */}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
||||
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||||
{isLoading && (
|
||||
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export default function BranchesView({
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function ChangesView({
|
||||
|
||||
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
|
||||
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function HistoryView({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -19,14 +19,12 @@ import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
|
||||
|
||||
type QuickSettingsContentProps = {
|
||||
isDarkMode: boolean;
|
||||
isMobile: boolean;
|
||||
preferences: QuickSettingsPreferences;
|
||||
onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function QuickSettingsContent({
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
preferences,
|
||||
onPreferenceChange,
|
||||
}: QuickSettingsContentProps) {
|
||||
@@ -45,7 +43,7 @@ export default function QuickSettingsContent({
|
||||
);
|
||||
|
||||
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')}>
|
||||
<div className={SETTING_ROW_CLASS}>
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function QuickSettingsPanelView() {
|
||||
<QuickSettingsPanelHeader />
|
||||
<QuickSettingsContent
|
||||
isDarkMode={isDarkMode}
|
||||
isMobile={isMobile}
|
||||
preferences={quickSettingsPreferences}
|
||||
onPreferenceChange={handlePreferenceChange}
|
||||
/>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function TerminalShortcutsPanel({
|
||||
wsRef,
|
||||
terminalRef,
|
||||
isConnected,
|
||||
bottomOffset = 'bottom-14',
|
||||
bottomOffset = 'bottom-0',
|
||||
}: TerminalShortcutsPanelProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [ctrlActive, setCtrlActive] = useState(false);
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function SidebarFooter({
|
||||
</div>
|
||||
|
||||
{/* Mobile settings */}
|
||||
<div className="px-3 pb-20 pt-2 md:hidden">
|
||||
<div className="px-3 pb-3 pt-2 md:hidden">
|
||||
<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]"
|
||||
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';
|
||||
|
||||
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
@@ -11,21 +12,6 @@ type TooltipProps = {
|
||||
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 {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
@@ -46,11 +32,54 @@ function Tooltip({
|
||||
content,
|
||||
position = 'top',
|
||||
className = '',
|
||||
delay = 500,
|
||||
delay = 350,
|
||||
}: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// Store the timer id without forcing re-renders while hovering.
|
||||
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 = () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
@@ -71,6 +100,23 @@ function Tooltip({
|
||||
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(() => {
|
||||
// Avoid delayed updates after unmount.
|
||||
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) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
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}
|
||||
{isVisible && (
|
||||
{isVisible && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={tooltipStyle || { position: 'fixed', top: '-9999px', left: '-9999px', opacity: 0 }}
|
||||
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',
|
||||
getPositionClasses(position),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
{/* Arrow */}
|
||||
<div className={cn('absolute w-0 h-0 border-4 border-transparent', getArrowClasses(position))} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user