mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 14:12:40 +08:00
feat: support message queue and add attention indicator
This commit is contained in:
@@ -145,6 +145,11 @@ const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type QueuedDraft = {
|
||||
content: string;
|
||||
images: File[];
|
||||
};
|
||||
|
||||
const getNotificationSessionSummary = (
|
||||
selectedSession: ProjectSession | null,
|
||||
fallbackInput: string,
|
||||
@@ -215,6 +220,18 @@ export function useChatComposerState({
|
||||
>(null);
|
||||
const inputValueRef = useRef(input);
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
// Prefer the stable backend-allocated id (selectedSession.id) but fall back
|
||||
// to currentSessionId for a just-established session that hasn't been
|
||||
// handed back to the parent's `selectedSession` prop yet.
|
||||
const sessionKey = selectedSession?.id || currentSessionId || null;
|
||||
|
||||
const [queuedDraft, setQueuedDraft] = useState<QueuedDraft | null>(() => {
|
||||
if (typeof window === 'undefined' || !sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
return saved ? { content: saved, images: [] } : null;
|
||||
});
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
@@ -555,7 +572,28 @@ export function useChatComposerState({
|
||||
) => {
|
||||
event.preventDefault();
|
||||
const currentInput = inputValueRef.current;
|
||||
if (!currentInput.trim() || isLoading || !selectedProject) {
|
||||
if (!currentInput.trim() || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A turn is already in flight: stash this message instead of sending it.
|
||||
// It's auto-flushed (re-running this same function) once the turn ends,
|
||||
// so it still goes through slash-command interception, image upload, etc.
|
||||
if (isLoading) {
|
||||
setQueuedDraft({ content: currentInput, images: attachedImages });
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
resetCommandMenuState();
|
||||
setIsTextareaExpanded(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
if (selectedProject) {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -799,6 +837,40 @@ export function useChatComposerState({
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
// Once the in-flight turn ends, replay the queued draft through the normal
|
||||
// submit path (slash commands, image upload, etc. all still apply).
|
||||
const wasLoadingRef = useRef(isLoading);
|
||||
useEffect(() => {
|
||||
const wasLoading = wasLoadingRef.current;
|
||||
wasLoadingRef.current = isLoading;
|
||||
if (!wasLoading || isLoading || !queuedDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
setTimeout(() => {
|
||||
handleSubmitRef.current?.(createFakeSubmitEvent());
|
||||
}, 0);
|
||||
}, [isLoading, queuedDraft]);
|
||||
|
||||
const editQueuedDraft = useCallback(() => {
|
||||
if (!queuedDraft) {
|
||||
return;
|
||||
}
|
||||
setQueuedDraft(null);
|
||||
setInput(queuedDraft.content);
|
||||
inputValueRef.current = queuedDraft.content;
|
||||
setAttachedImages(queuedDraft.images);
|
||||
textareaRef.current?.focus();
|
||||
}, [queuedDraft]);
|
||||
|
||||
const deleteQueuedDraft = useCallback(() => {
|
||||
setQueuedDraft(null);
|
||||
}, []);
|
||||
|
||||
// A voice transcript either fills the input (to edit before sending) or, when the
|
||||
// user tapped "stop and send", is submitted straight away. Mirror the value into
|
||||
// inputValueRef synchronously so handleSubmit reads the new text, not the stale state.
|
||||
@@ -837,6 +909,28 @@ export function useChatComposerState({
|
||||
}
|
||||
}, [input, selectedProjectId]);
|
||||
|
||||
// Switching sessions swaps in that session's queued draft (image
|
||||
// attachments can't survive a reload, so only the text is restored).
|
||||
useEffect(() => {
|
||||
if (!sessionKey) {
|
||||
setQueuedDraft(null);
|
||||
return;
|
||||
}
|
||||
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
|
||||
setQueuedDraft(saved ? { content: saved, images: [] } : null);
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (queuedDraft?.content) {
|
||||
safeLocalStorage.setItem(`queued_message_${sessionKey}`, queuedDraft.content);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`queued_message_${sessionKey}`);
|
||||
}
|
||||
}, [queuedDraft, sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
@@ -1044,6 +1138,9 @@ export function useChatComposerState({
|
||||
isDragActive,
|
||||
openImagePicker: open,
|
||||
handleSubmit,
|
||||
queuedDraft,
|
||||
editQueuedDraft,
|
||||
deleteQueuedDraft,
|
||||
handleVoiceTranscript,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const safeLocalStorage = {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_') || k.startsWith('queued_message_'));
|
||||
draftKeys.forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
@@ -174,6 +174,9 @@ function ChatInterface({
|
||||
isDragActive,
|
||||
openImagePicker,
|
||||
handleSubmit,
|
||||
queuedDraft,
|
||||
editQueuedDraft,
|
||||
deleteQueuedDraft,
|
||||
handleVoiceTranscript,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
@@ -405,6 +408,9 @@ function ChatInterface({
|
||||
onClearInput={handleClearInput}
|
||||
onSubmit={handleSubmit}
|
||||
isDragActive={isDragActive}
|
||||
queuedDraft={queuedDraft}
|
||||
onEditQueuedDraft={editQueuedDraft}
|
||||
onDeleteQueuedDraft={deleteQueuedDraft}
|
||||
attachedImages={attachedImages}
|
||||
onRemoveImage={(index) =>
|
||||
setAttachedImages((previous) =>
|
||||
|
||||
@@ -11,10 +11,11 @@ import type {
|
||||
RefObject,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check, ArrowUpIcon } from 'lucide-react';
|
||||
|
||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||
import type { QueuedDraft } from '../../hooks/useChatComposerState';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||
import type { ProviderModelOption } from '../../../../types/app';
|
||||
@@ -35,6 +36,7 @@ import ImageAttachment from './ImageAttachment';
|
||||
import VoiceInputButton from './VoiceInputButton';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
import QueuedMessageCard from './QueuedMessageCard';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
@@ -74,6 +76,9 @@ interface ChatComposerProps {
|
||||
onClearInput: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||
isDragActive: boolean;
|
||||
queuedDraft: QueuedDraft | null;
|
||||
onEditQueuedDraft: () => void;
|
||||
onDeleteQueuedDraft: () => void;
|
||||
attachedImages: File[];
|
||||
onRemoveImage: (index: number) => void;
|
||||
uploadingImages: Map<string, number>;
|
||||
@@ -129,6 +134,9 @@ export default function ChatComposer({
|
||||
onClearInput,
|
||||
onSubmit,
|
||||
isDragActive,
|
||||
queuedDraft,
|
||||
onEditQueuedDraft,
|
||||
onDeleteQueuedDraft,
|
||||
attachedImages,
|
||||
onRemoveImage,
|
||||
uploadingImages,
|
||||
@@ -267,6 +275,23 @@ export default function ChatComposer({
|
||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
||||
|
||||
const hasQueuedDraft = Boolean(queuedDraft);
|
||||
const canQueueDraft = isLoading && Boolean(input.trim());
|
||||
const submitHint = canQueueDraft
|
||||
? hasQueuedDraft
|
||||
? t('input.hintText.updateQueued', { defaultValue: 'Enter to update queued message' })
|
||||
: t('input.hintText.queue', { defaultValue: 'Enter to queue your next message' })
|
||||
: sendByCtrlEnter
|
||||
? t('input.hintText.ctrlEnter')
|
||||
: t('input.hintText.enter');
|
||||
const submitAriaLabel = canQueueDraft
|
||||
? hasQueuedDraft
|
||||
? t('input.queue.update', { defaultValue: 'Update queued message' })
|
||||
: t('input.queue.sendNext', { defaultValue: 'Queue next message' })
|
||||
: isLoading
|
||||
? t('input.stop')
|
||||
: t('input.send');
|
||||
|
||||
return (
|
||||
<div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
@@ -285,6 +310,15 @@ export default function ChatComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queuedDraft && (
|
||||
<QueuedMessageCard
|
||||
content={queuedDraft.content}
|
||||
imageCount={queuedDraft.images.length}
|
||||
onEdit={onEditQueuedDraft}
|
||||
onDelete={onDeleteQueuedDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
|
||||
{showFileDropdown && filteredFiles.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||
@@ -540,26 +574,37 @@ export default function ChatComposer({
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
|
||||
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||
input.trim() && !canQueueDraft ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
{submitHint}
|
||||
</div>
|
||||
<PromptInputSubmit
|
||||
onClick={
|
||||
isLoading
|
||||
? onAbortSession
|
||||
: isRecording
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
voiceStop({ send: true });
|
||||
}
|
||||
: undefined
|
||||
canQueueDraft
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}
|
||||
: isLoading
|
||||
? onAbortSession
|
||||
: isRecording
|
||||
? (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
voiceStop({ send: true });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()}
|
||||
aria-label={submitAriaLabel}
|
||||
title={submitAriaLabel}
|
||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||
>
|
||||
{isTranscribing ? <Loader2 className="h-4 w-4 animate-spin" /> : undefined}
|
||||
{isTranscribing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : canQueueDraft ? (
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
) : undefined}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
57
src/components/chat/view/subcomponents/QueuedMessageCard.tsx
Normal file
57
src/components/chat/view/subcomponents/QueuedMessageCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PencilIcon, XIcon } from 'lucide-react';
|
||||
|
||||
interface QueuedMessageCardProps {
|
||||
content: string;
|
||||
imageCount?: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function QueuedMessageCard({ content, imageCount = 0, onEdit, onDelete }: QueuedMessageCardProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div className="settings-content-enter mx-auto mb-2 max-w-[54.25rem] rounded-xl border border-dashed border-primary/25 bg-primary/[0.04] px-3 py-2">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60" aria-hidden />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-primary/70">
|
||||
<span>{t('input.queue.label', { defaultValue: 'Queued' })}</span>
|
||||
<span className="normal-case text-muted-foreground/60">
|
||||
· {t('input.queue.willSend', { defaultValue: 'Will send when this finishes' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 break-words text-sm text-foreground/90">{content}</p>
|
||||
{imageCount > 0 && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{imageCount} {imageCount === 1 ? 'image' : 'images'} attached
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
|
||||
title={t('input.queue.edit', { defaultValue: 'Edit queued message' })}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
|
||||
title={t('input.queue.delete', { defaultValue: 'Delete queued message' })}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type SidebarProps = {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
|
||||
@@ -26,6 +26,7 @@ function Sidebar({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
@@ -163,6 +164,7 @@ function Sidebar({
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
forceExpanded: searchMode === 'running',
|
||||
isProjectStarred,
|
||||
onEditingNameChange: setEditingName,
|
||||
|
||||
@@ -45,6 +45,7 @@ type SidebarProjectItemProps = {
|
||||
) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -87,6 +88,7 @@ export default function SidebarProjectItem({
|
||||
onDeleteSession,
|
||||
onLoadMoreSessions,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange,
|
||||
onStartEditingSession,
|
||||
@@ -399,6 +401,7 @@ export default function SidebarProjectItem({
|
||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
attentionSessionIds={attentionSessionIds}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -29,6 +29,7 @@ export type SidebarProjectListProps = {
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
loadingMoreProjects: Set<string>;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
forceExpanded?: boolean;
|
||||
isProjectStarred: (projectName: string) => boolean;
|
||||
onEditingNameChange: (value: string) => void;
|
||||
@@ -75,6 +76,7 @@ export default function SidebarProjectList({
|
||||
onLoadMoreSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
forceExpanded = false,
|
||||
isProjectStarred,
|
||||
onEditingNameChange,
|
||||
@@ -152,6 +154,7 @@ export default function SidebarProjectList({
|
||||
onDeleteSession={onDeleteSession}
|
||||
onLoadMoreSessions={onLoadMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
attentionSessionIds={attentionSessionIds}
|
||||
onNewSession={onNewSession}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
|
||||
@@ -17,6 +17,7 @@ type SidebarProjectSessionsProps = {
|
||||
hasMoreSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
activeSessions: SessionActivityMap;
|
||||
attentionSessionIds: ReadonlySet<string>;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -64,6 +65,7 @@ export default function SidebarProjectSessions({
|
||||
hasMoreSessions,
|
||||
isLoadingMoreSessions,
|
||||
activeSessions,
|
||||
attentionSessionIds,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -124,6 +126,7 @@ export default function SidebarProjectSessions({
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
isProcessing={activeSessions.has(session.id)}
|
||||
needsAttention={attentionSessionIds.has(session.id)}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -14,6 +14,7 @@ type SidebarSessionItemProps = {
|
||||
session: SessionWithProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
isProcessing: boolean;
|
||||
needsAttention: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -65,6 +66,7 @@ export default function SidebarSessionItem({
|
||||
session,
|
||||
selectedSession,
|
||||
isProcessing,
|
||||
needsAttention,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -82,7 +84,8 @@ export default function SidebarSessionItem({
|
||||
const isEditing = editingSession === session.id;
|
||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||
const editingContainerRef = useRef<HTMLDivElement>(null);
|
||||
const showRecentIndicator = !isProcessing && sessionView.isActive;
|
||||
const showAttentionIndicator = needsAttention && !isSelected;
|
||||
const showRecentIndicator = !showAttentionIndicator && !isProcessing && sessionView.isActive;
|
||||
|
||||
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
|
||||
// would visually hide it. While editing, dismiss only when the user clicks outside
|
||||
@@ -120,13 +123,23 @@ export default function SidebarSessionItem({
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{showRecentIndicator && (
|
||||
{(showAttentionIndicator || showRecentIndicator) && (
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
||||
<Tooltip
|
||||
content={showAttentionIndicator
|
||||
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
|
||||
: t('tooltips.activeSessionIndicator')}
|
||||
position="right"
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('tooltips.activeSessionIndicator')}
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
|
||||
aria-label={showAttentionIndicator
|
||||
? t('tooltips.attentionRequiredIndicator', { defaultValue: 'Session needs attention' })
|
||||
: t('tooltips.activeSessionIndicator')}
|
||||
className={cn(
|
||||
'h-2 w-2 animate-pulse rounded-full',
|
||||
showAttentionIndicator ? 'bg-amber-500' : 'bg-green-500',
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user