ad6e80192d
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* @file Tooltip.tsx
|
|
* @description The dashboard's tooltip look, and the one renderer that gives it
|
|
* to every control in the app.
|
|
*
|
|
* ## Why a global renderer instead of a wrapper component
|
|
* The UI already labels its icon-only controls with the native `title`
|
|
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
|
* touching every one of those call sites to get a designed bubble, and the
|
|
* next `title=` someone writes would be back to the browser's grey box.
|
|
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
|
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
|
* by removing the attribute — its ancestors' too, or the browser falls back
|
|
* to the nearest one's — for as long as the bubble is up, and restores them
|
|
* on the way out. Nothing else in the app has to know it exists, and a control
|
|
* keeps its native tooltip (and its accessible name) when JS has not run.
|
|
* While the title is parked, a control with no other name (icon-only) carries
|
|
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
|
|
* bubble goes away with its control if that is removed from the page.
|
|
*
|
|
* ## The look
|
|
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
|
* hard to read and lands over the thing it describes. It flips above/below and
|
|
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
|
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
|
* built from the theme's own tokens so it is legible in light mode too (the
|
|
* old bubble was a hardcoded near-black).
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
|
export const TOOLTIP_SURFACE =
|
|
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
|
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
|
"whitespace-pre-wrap break-words";
|
|
|
|
/** How long the pointer must rest on a control before its tooltip appears. */
|
|
const SHOW_DELAY_MS = 350;
|
|
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
|
const GAP = 8;
|
|
|
|
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
|
|
* `[data-tip]` matches the control whose title is parked, so moving between
|
|
* its own children still resolves to it instead of re-opening the bubble. */
|
|
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
|
|
|
type Anchored = { text: string; rect: DOMRect };
|
|
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
|
|
|
/**
|
|
* Takes `el`'s title into `data-tip`. React removing `title` while it is
|
|
* parked calls removeAttribute on an attribute that is already gone: no
|
|
* MutationRecord, nothing to see — and putting the parked copy back would
|
|
* write stale text for good. So, on THIS element and only while parked, that
|
|
* call is taken as React's intent: the parked copy is dropped and `onDrop`
|
|
* runs. (React DOM calls `node.removeAttribute`, the instance method; `unpark`
|
|
* deletes the shim.) No global prototype is touched.
|
|
*/
|
|
function park(el: HTMLElement, text: string, onDrop?: () => void) {
|
|
el.dataset.tip = text;
|
|
el.removeAttribute("title");
|
|
el.removeAttribute = (name: string) => {
|
|
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
|
delete el.dataset.tip;
|
|
onDrop?.();
|
|
}
|
|
Element.prototype.removeAttribute.call(el, name);
|
|
};
|
|
}
|
|
|
|
/** Undoes {@link park}. */
|
|
function unpark(el: HTMLElement) {
|
|
// Drop the instance shim; the prototype method is back.
|
|
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
|
const text = el.dataset.tip;
|
|
if (text === undefined) return;
|
|
// React re-set `title` while it was parked (a poll flipped the hint): that
|
|
// value is current, the parked copy is stale. React never re-applies an
|
|
// unchanged prop, so writing the old one back would win for good.
|
|
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
|
delete el.dataset.tip;
|
|
}
|
|
|
|
/**
|
|
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
|
* designed tooltip; nothing else has to change.
|
|
*/
|
|
export function GlobalTooltip() {
|
|
const [tip, setTip] = useState<Anchored | null>(null);
|
|
const [pos, setPos] = useState<Placed | null>(null);
|
|
const bubble = useRef<HTMLDivElement>(null);
|
|
// The element whose `title` is currently parked in `data-tip`, so it can be
|
|
// put back even if the pointer never leaves it (a route change, a re-render
|
|
// that unmounts it, Escape).
|
|
const parked = useRef<HTMLElement | null>(null);
|
|
// Ancestors of `parked` whose titles are parked with it (see `open`).
|
|
const silenced = useRef<HTMLElement[]>([]);
|
|
const timer = useRef<number | undefined>(undefined);
|
|
// The element whose delay is running, so re-entering it does not restart it.
|
|
const pending = useRef<HTMLElement | null>(null);
|
|
|
|
const restore = useCallback(() => {
|
|
window.clearTimeout(timer.current);
|
|
pending.current = null;
|
|
silenced.current.forEach(unpark);
|
|
silenced.current = [];
|
|
const el = parked.current;
|
|
if (el) {
|
|
unpark(el);
|
|
if (el.dataset.tipAria !== undefined) {
|
|
el.removeAttribute("aria-label");
|
|
delete el.dataset.tipAria;
|
|
}
|
|
parked.current = null;
|
|
}
|
|
setTip(null);
|
|
setPos(null);
|
|
}, []);
|
|
|
|
const open = useCallback(
|
|
(el: HTMLElement, delay: number) => {
|
|
const text = (el.getAttribute("title") || "").trim();
|
|
if (!text) return;
|
|
restore();
|
|
pending.current = el;
|
|
timer.current = window.setTimeout(() => {
|
|
pending.current = null;
|
|
// Re-read at fire time: the control may have been re-rendered, removed
|
|
// or had its title changed while the delay was running.
|
|
if (!el.isConnected) return;
|
|
const current = (el.getAttribute("title") || "").trim();
|
|
if (!current) return;
|
|
park(el, current, restore);
|
|
// With its own title gone the browser shows the nearest ANCESTOR's
|
|
// native one instead (a pane header's drag hint over its Close
|
|
// button's bubble), so every ancestor title is parked too.
|
|
for (let a = el.parentElement; a; a = a.parentElement) {
|
|
const t = a.getAttribute("title");
|
|
if (t === null) continue;
|
|
park(a, t);
|
|
silenced.current.push(a);
|
|
}
|
|
// The title was an icon-only control's ONLY name; keep it named.
|
|
if (
|
|
!el.hasAttribute("aria-label") &&
|
|
!el.hasAttribute("aria-labelledby") &&
|
|
!el.textContent?.trim()
|
|
) {
|
|
el.setAttribute("aria-label", current);
|
|
el.dataset.tipAria = "";
|
|
}
|
|
parked.current = el;
|
|
setTip({ text: current, rect: el.getBoundingClientRect() });
|
|
}, delay);
|
|
},
|
|
[restore]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const target = (e: Event) => {
|
|
const node = e.target;
|
|
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
|
};
|
|
const onOver = (e: PointerEvent) => {
|
|
const el = target(e);
|
|
// Already showing or already counting down for this control: a pointer
|
|
// crossing its children must neither hide nor restart it.
|
|
if (!el || el === parked.current || el === pending.current) return;
|
|
open(el, SHOW_DELAY_MS);
|
|
};
|
|
const onOut = (e: PointerEvent) => {
|
|
// Moving between children of the same control must not flicker.
|
|
const el = target(e);
|
|
const to = e.relatedTarget;
|
|
if (el && to instanceof Node && el.contains(to)) return;
|
|
if (el || parked.current) restore();
|
|
};
|
|
// Keyboard users get the same tooltip, immediately — a focus ring that
|
|
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
|
// Only KEYBOARD focus, though: a click focuses too, and without the
|
|
// `:focus-visible` check it reopened the bubble the pointerdown just
|
|
// closed, with no delay. Hover keeps its own path.
|
|
const onFocus = (e: FocusEvent) => {
|
|
const el = target(e);
|
|
if (!el || el === parked.current) return;
|
|
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
|
|
open(el, 0);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") restore();
|
|
};
|
|
|
|
document.addEventListener("pointerover", onOver);
|
|
document.addEventListener("pointerout", onOut);
|
|
document.addEventListener("pointerdown", restore);
|
|
document.addEventListener("focusin", onFocus);
|
|
document.addEventListener("focusout", restore);
|
|
document.addEventListener("keydown", onKey);
|
|
// A bubble anchored to a rect is wrong the moment its anchor moves — but
|
|
// only a scroll of the document or of a box containing the anchor moves
|
|
// it. A log tailing in another panel must not close the bubble.
|
|
const onScroll = (e: Event) => {
|
|
const anchor = parked.current || pending.current;
|
|
const t = e.target;
|
|
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
|
|
};
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", restore);
|
|
window.addEventListener("blur", restore);
|
|
return () => {
|
|
document.removeEventListener("pointerover", onOver);
|
|
document.removeEventListener("pointerout", onOut);
|
|
document.removeEventListener("pointerdown", restore);
|
|
document.removeEventListener("focusin", onFocus);
|
|
document.removeEventListener("focusout", restore);
|
|
document.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", restore);
|
|
window.removeEventListener("blur", restore);
|
|
restore();
|
|
};
|
|
}, [open, restore]);
|
|
|
|
// A control unmounted under a still cursor fires no pointerout: without this
|
|
// its bubble stays up over whatever replaced it. The same goes for a title
|
|
// re-rendered under it — the bubble would keep saying the old text, so it
|
|
// hides and `restore` keeps the new attribute.
|
|
useEffect(() => {
|
|
if (!tip) return;
|
|
const watch = new MutationObserver((records) => {
|
|
const el = parked.current;
|
|
if (!el) return;
|
|
if (
|
|
!el.isConnected ||
|
|
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
|
|
)
|
|
restore();
|
|
});
|
|
watch.observe(document.body, { childList: true, subtree: true });
|
|
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
|
|
return () => watch.disconnect();
|
|
}, [tip, restore]);
|
|
|
|
// Placed after the first paint, when the bubble's real size is known —
|
|
// measuring it is the only way to flip and clamp without guessing.
|
|
useEffect(() => {
|
|
if (!tip || !bubble.current) return;
|
|
const { width, height } = bubble.current.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth;
|
|
const vh = window.innerHeight;
|
|
const centre = tip.rect.left + tip.rect.width / 2;
|
|
|
|
let left = centre - width / 2;
|
|
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
|
// Below by default; above only when below would run off the screen AND
|
|
// above actually fits, so a control at the very bottom still gets a bubble.
|
|
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
|
|
|
setPos({
|
|
left,
|
|
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
|
// The arrow points at the control even after the bubble was clamped.
|
|
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
|
above,
|
|
});
|
|
}, [tip]);
|
|
|
|
if (!tip) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={bubble}
|
|
role="tooltip"
|
|
data-testid="global-tooltip"
|
|
style={{
|
|
position: "fixed",
|
|
left: pos?.left ?? -9999,
|
|
top: pos?.top ?? -9999,
|
|
opacity: pos ? 1 : 0,
|
|
transition: "opacity 120ms ease-out",
|
|
}}
|
|
className={TOOLTIP_SURFACE}
|
|
>
|
|
{tip.text}
|
|
{/* Two borders only: a fully bordered diamond draws its other two
|
|
edges straight across the bubble it is attached to. */}
|
|
<span
|
|
aria-hidden
|
|
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
|
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
|
pos?.above ? "border-b border-r" : "border-l border-t"
|
|
} border-border`}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
export default GlobalTooltip;
|