daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
/**
|
|
* @file Sagi.tsx
|
|
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
|
* in Layout) so it persists across routes and shares the single WebSocket.
|
|
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
|
* detection, and route navigation. Reactive personality + status/Ask come
|
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
|
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
|
* they never spill off any screen edge regardless of where the mascot is docked.
|
|
*
|
|
* The "do the job" path: an unmatched Ask query starts a claude session in
|
|
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
|
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
|
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
|
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
|
* @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.
|
|
*
|
|
* ## 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
|
|
* - `./SagiAvatar`
|
|
* - `./SpeechBubble`
|
|
* - `./SagiPanel`
|
|
* - `./useSagiBrain`
|
|
* - `./useSagiPosition`
|
|
* - `./intents`
|
|
* - `./prefs`
|
|
*
|
|
* ## Public surface
|
|
* - `Sagi` — 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**
|
|
* 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,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { SagiAvatar } from "./SagiAvatar";
|
|
import { PetSprite } from "./PetSprite";
|
|
import { PetMenu } from "./PetMenu";
|
|
import { SpeechBubble } from "./SpeechBubble";
|
|
import { SagiPanel } from "./SagiPanel";
|
|
import { useSagiBrain } from "./useSagiBrain";
|
|
import { useSagiPosition } from "./useSagiPosition";
|
|
import { matchIntent } from "./intents";
|
|
import { sagiPrefs } from "./prefs";
|
|
import { api } from "../../lib/api";
|
|
import "./sagi.css";
|
|
|
|
const FLYOUT_GAP = 10; // px between avatar and flyout
|
|
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
|
|
|
/** True while focus sits where typing happens (an input, a terminal's textarea). */
|
|
function isEditable(el: EventTarget | null): boolean {
|
|
if (!(el instanceof HTMLElement)) return false;
|
|
return (
|
|
el.isContentEditable ||
|
|
el.tagName === "INPUT" ||
|
|
el.tagName === "TEXTAREA" ||
|
|
el.tagName === "SELECT"
|
|
);
|
|
}
|
|
|
|
interface Anchor {
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
side: "left" | "right";
|
|
openUp: boolean;
|
|
dragging: boolean;
|
|
}
|
|
|
|
/** The OS "reduce motion" preference, followed live. */
|
|
export function usePrefersReducedMotion(): boolean {
|
|
const [reduced, setReduced] = useState(
|
|
() =>
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
);
|
|
useEffect(() => {
|
|
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
if (!mq) return;
|
|
const onChange = () => setReduced(mq.matches);
|
|
mq.addEventListener?.("change", onChange);
|
|
return () => mq.removeEventListener?.("change", onChange);
|
|
}, []);
|
|
return reduced;
|
|
}
|
|
|
|
/**
|
|
* Fixed-position wrapper that places its content next to the avatar and clamps
|
|
* it inside the viewport. It measures itself (and re-measures on content/size
|
|
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
|
* into view instead of being cropped.
|
|
*/
|
|
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
|
function SagiFlyout({
|
|
anchor,
|
|
children,
|
|
front = false,
|
|
}: {
|
|
anchor: Anchor;
|
|
children: ReactNode;
|
|
front?: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
|
|
|
const place = useCallback(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const w = el.offsetWidth;
|
|
const h = el.offsetHeight;
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
|
|
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
|
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
|
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
|
|
|
// Vertical: prefer above the mascot (feels natural). Only drop below when
|
|
// there isn't room above - i.e. the mascot is near the top edge.
|
|
const above = anchor.top - h - FLYOUT_GAP;
|
|
const below = anchor.top + anchor.size + FLYOUT_GAP;
|
|
let top = above >= VIEWPORT_MARGIN ? above : below;
|
|
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
|
|
|
setStyle({ left, top, visibility: "visible" });
|
|
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
|
|
|
useLayoutEffect(() => {
|
|
place();
|
|
const el = ref.current;
|
|
if (!el || typeof ResizeObserver === "undefined") return;
|
|
const ro = new ResizeObserver(() => place());
|
|
ro.observe(el);
|
|
window.addEventListener("resize", place);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", place);
|
|
};
|
|
}, [place]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="sagi-flyout"
|
|
// Mirrors the avatar button: eased left/top so the flyout glides along
|
|
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
|
|
data-dragging={anchor.dragging ? "1" : "0"}
|
|
data-front={front ? "1" : undefined}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Sagi() {
|
|
const { t } = useTranslation("settings");
|
|
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
|
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
|
const [open, setOpen] = useState(false);
|
|
// The right-click pet menu (`PetMenu.tsx`).
|
|
const [menu, setMenu] = useState(false);
|
|
const reducedMotion = usePrefersReducedMotion();
|
|
const navigate = useNavigate();
|
|
const brain = useSagiBrain();
|
|
const place = useSagiPosition(size);
|
|
|
|
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
|
useEffect(
|
|
() =>
|
|
sagiPrefs.subscribe(() => {
|
|
setEnabled(sagiPrefs.getEnabled());
|
|
setPet(sagiPrefs.getPet());
|
|
setSize(sagiPrefs.getSize());
|
|
}),
|
|
[]
|
|
);
|
|
|
|
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
|
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
|
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
|
e.preventDefault();
|
|
setOpen((v) => !v);
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [enabled]);
|
|
|
|
const onNavigate = useCallback(
|
|
(route: string) => {
|
|
navigate(route);
|
|
setOpen(false);
|
|
},
|
|
[navigate]
|
|
);
|
|
|
|
const onAsk = useCallback(
|
|
(query: string): string | null => {
|
|
const result = matchIntent(query, brain.status);
|
|
if (result.kind === "answer") return result.text;
|
|
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
|
// fresh claude session in it carrying the user's prompt as its first
|
|
// message, then open that workspace's session view on the new pane.
|
|
// Fire-and-forget — the panel already clears its composer on null and
|
|
// closes; errors surface as an answer instead of silently swallowing.
|
|
const prompt = result.prompt;
|
|
brain.setThinking(true);
|
|
(async () => {
|
|
try {
|
|
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
|
const run = await api.run.start({
|
|
laneId: lane.id,
|
|
cwd: lane.cwd,
|
|
initialPrompt: prompt,
|
|
permissionMode: "acceptEdits",
|
|
});
|
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
|
} finally {
|
|
brain.setThinking(false);
|
|
}
|
|
})();
|
|
return null;
|
|
},
|
|
[brain, navigate, t]
|
|
);
|
|
|
|
if (!enabled) return null;
|
|
|
|
const anchor: Anchor = {
|
|
left: place.left,
|
|
top: place.top,
|
|
size: place.size,
|
|
side: place.side,
|
|
openUp: place.openUp,
|
|
dragging: place.dragging,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
|
|
does not - it is Sagi talking, and it has to stay by its mouth. */}
|
|
{!place.dragging && open && (
|
|
<SagiFlyout anchor={anchor}>
|
|
<SagiPanel
|
|
status={brain.status}
|
|
muted={brain.muted}
|
|
onToggleMute={brain.toggleMute}
|
|
onClearAlerts={brain.clearAlerts}
|
|
onNavigate={onNavigate}
|
|
onAsk={onAsk}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
</SagiFlyout>
|
|
)}
|
|
|
|
{!place.dragging && menu && (
|
|
<SagiFlyout anchor={anchor} front>
|
|
<PetMenu
|
|
pet={pet}
|
|
size={size}
|
|
onClose={() => setMenu(false)}
|
|
onSettings={() => {
|
|
setMenu(false);
|
|
navigate("/settings/appearance#pet");
|
|
}}
|
|
/>
|
|
</SagiFlyout>
|
|
)}
|
|
|
|
{!open && !menu && brain.bubble && (
|
|
<SagiFlyout anchor={anchor}>
|
|
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
|
</SagiFlyout>
|
|
)}
|
|
|
|
<button
|
|
className="sagi-avatar-btn"
|
|
data-dragging={place.dragging ? "1" : "0"}
|
|
style={{ left: place.left, top: place.top, width: size, height: size }}
|
|
onPointerDown={place.onPointerDown}
|
|
onPointerMove={place.onPointerMove}
|
|
onPointerUp={place.onPointerUp}
|
|
onKeyDown={place.onKeyDown}
|
|
onClick={() => {
|
|
// A drag just ended - swallow the synthetic click so the panel
|
|
// doesn't toggle when the user only repositioned the avatar.
|
|
if (place.consumeDrag()) return;
|
|
setOpen((v) => !v);
|
|
}}
|
|
// Right-click: the pet menu (hide, size, choose, upload, import).
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
setOpen(false);
|
|
setMenu(true);
|
|
}}
|
|
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
|
aria-expanded={open}
|
|
title={t("sagi.avatarTitle")}
|
|
>
|
|
{pet.id === "sagi" || !("kind" in pet) ? (
|
|
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
|
) : (
|
|
<PetSprite
|
|
id={pet.id}
|
|
kind={pet.kind}
|
|
mood={brain.mood}
|
|
size={size}
|
|
reducedMotion={reducedMotion}
|
|
// Deleted elsewhere, or unreadable: back to Sagi.
|
|
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
|
/>
|
|
)}
|
|
{brain.status.errorCount > 0 && (
|
|
<span className="sagi-error-dot" aria-hidden>
|
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</>
|
|
);
|
|
}
|