4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
/**
|
|
* @file useSagiPosition.ts
|
|
* @description AssistiveTouch-style draggable docking for the Sagi avatar. The
|
|
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
|
|
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
|
* nearest left/right edge, remembering its vertical offset (persisted as a
|
|
* viewport fraction so it survives resizes). A small movement threshold tells
|
|
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
|
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
|
* `moving` names the way it is heading — while dragged (cleared once the
|
|
* pointer rests) and during the glide to its edge — so a pet can run that way.
|
|
* It never rests over the fixed bottom status bar; by default it docks
|
|
* bottom-right just above it.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `./prefs`
|
|
*
|
|
* ## Public surface
|
|
* - `SAGI_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `SAGI_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `SagiPlacement` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `useSagiPosition` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **SAGI_SIZE**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **SAGI_MARGIN**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **SagiPlacement**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **useSagiPosition**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { sagiPrefs, type SagiPos } from "./prefs";
|
|
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
|
|
|
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
|
export const SAGI_SIZE = 68;
|
|
export const SAGI_MARGIN = 16;
|
|
const DRAG_THRESHOLD = 5;
|
|
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
|
|
const STATUS_BAR = 24;
|
|
// One arrow-key step, in px.
|
|
const KEY_STEP = 40;
|
|
// A held but unmoving drag stops running after this; also the snap glide's length.
|
|
const MOVE_SETTLE_MS = 300;
|
|
|
|
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
|
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
|
|
|
function defaultPos(): SagiPos {
|
|
// Bottom-right, above the status bar: a centred dock covered row controls.
|
|
return { side: "right", y: 1 };
|
|
}
|
|
|
|
/** Resting top-left screen coords for a docked position. */
|
|
function restingScreen(pos: SagiPos, size: number) {
|
|
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
|
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
|
|
const top = SAGI_MARGIN + pos.y * avail;
|
|
return { left, top };
|
|
}
|
|
|
|
export interface SagiPlacement {
|
|
/** Avatar top-left, in screen px. */
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
side: "left" | "right";
|
|
/** True when the avatar sits in the lower half - flyouts open upward. */
|
|
openUp: boolean;
|
|
dragging: boolean;
|
|
/** The way the avatar is moving horizontally right now, if it is. */
|
|
moving: "left" | "right" | null;
|
|
onPointerDown: (e: ReactPointerEvent) => void;
|
|
onPointerMove: (e: ReactPointerEvent) => void;
|
|
onPointerUp: (e: ReactPointerEvent) => void;
|
|
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
|
consumeDrag: () => boolean;
|
|
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
|
|
onKeyDown: (e: ReactKeyboardEvent) => void;
|
|
}
|
|
|
|
/** `size`: the avatar's current footprint (the pet size pref), in px. */
|
|
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
|
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
|
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
|
const [, force] = useState(0); // re-derive resting coords on resize
|
|
const [moving, setMoving] = useState<"left" | "right" | null>(null);
|
|
const moveTimer = useRef(0);
|
|
const lastX = useRef(0);
|
|
const heading = useCallback((dir: "left" | "right" | null) => {
|
|
window.clearTimeout(moveTimer.current);
|
|
setMoving(dir);
|
|
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
|
|
}, []);
|
|
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
|
|
|
|
const draggedRef = useRef(false);
|
|
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
|
const movedRef = useRef(false);
|
|
// Latest dragged coords, mirrored in a ref so pointerup can read them
|
|
// synchronously - the setDrag state may not have committed yet under React's
|
|
// event batching, so we never rely on its functional-updater `cur`.
|
|
const liveRef = useRef<{ left: number; top: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => force((n) => n + 1);
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, []);
|
|
|
|
const resting = restingScreen(pos, size);
|
|
const screen = drag ?? resting;
|
|
|
|
const onPointerDown = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
// Capture so the avatar keeps receiving move/up events even when the
|
|
// pointer leaves it - essential for a fast, 1:1 drag.
|
|
try {
|
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
} catch {
|
|
/* capture unsupported - window-free fallback still works via props */
|
|
}
|
|
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
|
lastX.current = e.clientX;
|
|
movedRef.current = false;
|
|
},
|
|
[screen.left, screen.top]
|
|
);
|
|
|
|
const onPointerMove = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
const start = startRef.current;
|
|
if (!start) return;
|
|
const dx = e.clientX - start.px;
|
|
const dy = e.clientY - start.py;
|
|
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
movedRef.current = true;
|
|
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
|
const top = Math.min(
|
|
vh() - size - SAGI_MARGIN - STATUS_BAR,
|
|
Math.max(SAGI_MARGIN, start.top + dy)
|
|
);
|
|
liveRef.current = { left, top };
|
|
setDrag({ left, top });
|
|
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
|
|
const stepX = e.clientX - lastX.current;
|
|
if (Math.abs(stepX) >= 2) {
|
|
lastX.current = e.clientX;
|
|
heading(stepX > 0 ? "right" : "left");
|
|
}
|
|
},
|
|
[size, heading]
|
|
);
|
|
|
|
const onPointerUp = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
try {
|
|
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
const live = liveRef.current;
|
|
if (live) {
|
|
draggedRef.current = true;
|
|
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
|
|
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
|
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
|
const next: SagiPos = { side, y };
|
|
sagiPrefs.setPos(next);
|
|
setPos(next);
|
|
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
|
// Run along the glide to the edge, when there is a real distance to cover.
|
|
const glide = restingScreen(next, size).left - live.left;
|
|
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
|
|
}
|
|
liveRef.current = null;
|
|
startRef.current = null;
|
|
movedRef.current = false;
|
|
},
|
|
[size, heading]
|
|
);
|
|
|
|
const onKeyDown = useCallback(
|
|
(e: ReactKeyboardEvent) => {
|
|
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
|
let next: SagiPos | null = null;
|
|
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
|
|
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
|
|
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
|
|
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
|
|
}
|
|
if (!next) return;
|
|
e.preventDefault();
|
|
if (next.side !== pos.side) heading(next.side);
|
|
sagiPrefs.setPos(next);
|
|
setPos(next);
|
|
},
|
|
[pos, size, heading]
|
|
);
|
|
|
|
const consumeDrag = useCallback(() => {
|
|
const was = draggedRef.current;
|
|
draggedRef.current = false;
|
|
return was;
|
|
}, []);
|
|
|
|
return {
|
|
left: screen.left,
|
|
top: screen.top,
|
|
size,
|
|
side: pos.side,
|
|
openUp: screen.top + size / 2 > vh() / 2,
|
|
dragging: drag !== null,
|
|
moving,
|
|
onPointerDown,
|
|
onPointerMove,
|
|
onPointerUp,
|
|
consumeDrag,
|
|
onKeyDown,
|
|
};
|
|
}
|