Add Tooltip component and show sensitive message count on auto-redact
Introduce a reusable Tooltip component with delayed hover reveal, viewport-aware horizontal nudging, and smooth CSS entrance animation. Supports top/bottom positioning via a data-side attribute. FilterPanel now wraps the auto-redact checkbox in a Tooltip that explains what auto-redaction detects. When sensitive messages exist in the current view, a red pill badge displays the count next to the label, giving users immediate visibility into how many messages contain detectable secrets before toggling auto-redact on. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,17 @@ import React, { useState } from "react";
|
|||||||
import type { MessageCategory } from "../lib/types";
|
import type { MessageCategory } from "../lib/types";
|
||||||
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
|
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
|
||||||
import { CATEGORY_COLORS } from "../lib/constants";
|
import { CATEGORY_COLORS } from "../lib/constants";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
enabledCategories: Set<MessageCategory>;
|
enabledCategories: Set<MessageCategory>;
|
||||||
onToggle: (cat: MessageCategory) => void;
|
onToggle: (cat: MessageCategory) => void;
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
onAutoRedactToggle: (enabled: boolean) => void;
|
onAutoRedactToggle: (enabled: boolean) => void;
|
||||||
|
sensitiveCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) {
|
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle, sensitiveCount }: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const enabledCount = enabledCategories.size;
|
const enabledCount = enabledCategories.size;
|
||||||
@@ -69,6 +71,11 @@ export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border-muted">
|
<div className="mt-3 pt-3 border-t border-border-muted">
|
||||||
|
<Tooltip
|
||||||
|
content="Automatically detect and replace sensitive content (API keys, tokens, passwords, emails, IPs, etc.) with placeholder labels"
|
||||||
|
side="top"
|
||||||
|
delayMs={150}
|
||||||
|
>
|
||||||
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
|
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -79,8 +86,14 @@ export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, on
|
|||||||
<svg className="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-body text-foreground-secondary">Auto-redact sensitive</span>
|
<span className="text-body text-foreground-secondary flex-1">Auto-redact sensitive</span>
|
||||||
|
{sensitiveCount > 0 && (
|
||||||
|
<span className="text-caption tabular-nums px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/20 ml-auto">
|
||||||
|
{sensitiveCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
81
src/client/components/Tooltip.tsx
Normal file
81
src/client/components/Tooltip.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: React.ReactNode;
|
||||||
|
children: React.ReactElement;
|
||||||
|
delayMs?: number;
|
||||||
|
side?: "top" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ content, children, delayMs = 150, side = "top" }: Props) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLElement>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
timerRef.current = setTimeout(() => setVisible(true), delayMs);
|
||||||
|
}, [delayMs]);
|
||||||
|
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clean up timer on unmount
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
|
// Recompute position when visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !triggerRef.current) return;
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPosition({
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: side === "top" ? rect.top : rect.bottom,
|
||||||
|
});
|
||||||
|
}, [visible, side]);
|
||||||
|
|
||||||
|
// Nudge tooltip horizontally if it overflows the viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !tooltipRef.current || !position) return;
|
||||||
|
const el = tooltipRef.current;
|
||||||
|
const tooltipRect = el.getBoundingClientRect();
|
||||||
|
const pad = 8;
|
||||||
|
if (tooltipRect.left < pad) {
|
||||||
|
el.style.transform = `translateX(${pad - tooltipRect.left}px)`;
|
||||||
|
} else if (tooltipRect.right > window.innerWidth - pad) {
|
||||||
|
el.style.transform = `translateX(${window.innerWidth - pad - tooltipRect.right}px)`;
|
||||||
|
} else {
|
||||||
|
el.style.transform = "";
|
||||||
|
}
|
||||||
|
}, [visible, position]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{React.cloneElement(children, {
|
||||||
|
ref: triggerRef,
|
||||||
|
onMouseEnter: show,
|
||||||
|
onMouseLeave: hide,
|
||||||
|
onFocus: show,
|
||||||
|
onBlur: hide,
|
||||||
|
})}
|
||||||
|
{visible && position && (
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
role="tooltip"
|
||||||
|
className="tooltip-popup"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: position.x,
|
||||||
|
top: side === "top" ? position.y - 8 : position.y + 8,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
data-side={side}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -552,3 +552,43 @@ mark.search-highlight {
|
|||||||
@apply focus-visible:ring-red-500;
|
@apply focus-visible:ring-red-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
Tooltip
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tooltip-popup {
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||||
|
animation: tooltip-in 120ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-popup[data-side="top"] {
|
||||||
|
transform-origin: bottom center;
|
||||||
|
translate: -50% -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-popup[data-side="bottom"] {
|
||||||
|
transform-origin: top center;
|
||||||
|
translate: -50% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltip-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
scale: 0.96;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user