Files
AgentHub/client/src/components/Sagi/sagi.css
T
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00

689 lines
17 KiB
CSS

/**
* sagi.css - animations + mood expressions for the Sagi companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
* Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
.sagi-avatar {
overflow: visible;
cursor: pointer;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
/* Every mood animation pivots here: horizontally centred, near the feet, so a
breathe/sway/rock reads as the mascot moving in place. MUST stay a
percentage - on the root <svg> a length would be resolved against the 68px
CSS box, not the 938-unit viewBox, and an origin ~9x the box away turns a
1.035 scale into a ~20px lurch (which is also what made the speech bubble
look detached, since the bubble is positioned from the box, not the paint). */
transform-origin: 50% 92%;
}
/* ── palette ── */
/* The mascot artwork is a flat trace (186 self-colored paths) with no part
grouping beyond the eyes, so there is no shared fill/stroke palette to set
here - every path carries its own `fill`. Only the extracted eye parts and
the new overlay glyphs need rules. */
.sagi-sparkle path {
fill: #fde68a;
}
.sagi-zzz text,
.sagi-bang text {
fill: #a5b4fc;
font-family: "JetBrains Mono", monospace;
font-size: 44px;
font-weight: 700;
}
/* ── default visibility: hide mood-only overlays ── */
.sagi-zzz,
.sagi-bang,
.sagi-sparkle {
display: none;
}
/* ── happy ── */
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
display: block;
}
/* ── watching ── (attentive sway handled below via whole-mascot transform) */
/* ── worried ── (whole-mascot shake handled below) */
/* ── stuck ── (alert bang) */
.sagi-avatar[data-mood="stuck"] .sagi-bang {
display: block;
}
/* ── thinking ── head-tilt rock handled below */
/* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */
.sagi-avatar[data-mood="sleeping"] .sagi-eye-blink,
.sagi-avatar[data-mood="disconnected"] .sagi-eye-blink {
transform: scaleY(0.08);
}
.sagi-avatar[data-mood="sleeping"] .sagi-zzz {
display: block;
}
.sagi-avatar[data-mood="disconnected"] {
opacity: 0.5;
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ── eyes: per-eye blink transform-origin ──
`transform-box: view-box` puts the reference box's 0,0 on the viewBox's
min-x/min-y ("253.5 106"), so these are mascot.svg coordinates minus that
offset. Getting it wrong slides the eyelid off the eye as it closes. */
.sagi-eye-blink,
.sagi-sparkle {
transform-box: view-box;
}
.sagi-eye-left .sagi-eye-blink {
transform-origin: 306px 269px; /* mascot 560,375 */
}
.sagi-eye-right .sagi-eye-blink {
transform-origin: 570px 269px; /* mascot 823,375 */
}
/* ════════ animations ════════ */
@keyframes sagi-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes sagi-blink {
0%,
92%,
100% {
transform: scaleY(1);
}
96% {
transform: scaleY(0.1);
}
}
@keyframes sagi-shake {
0%,
100% {
transform: translateX(0) scale(1);
}
20% {
transform: translateX(-3px) scale(1.01);
}
40% {
transform: translateX(3px) scale(0.99);
}
60% {
transform: translateX(-2px) scale(1.01);
}
80% {
transform: translateX(2px) scale(1);
}
}
@keyframes sagi-bob {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-4px) rotate(-4deg);
}
50% {
transform: translateY(-6px) rotate(0deg);
}
75% {
transform: translateY(-4px) rotate(4deg);
}
}
@keyframes sagi-sparkle-twinkle {
0%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@keyframes sagi-sway {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-2.5deg);
}
75% {
transform: rotate(2.5deg);
}
}
/* Pondering tilt that always comes back upright - the old -6deg..-11deg range
never returned to 0, so a thinking Sagi just sat there permanently crooked. */
@keyframes sagi-rock {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-8deg);
}
}
@keyframes sagi-droop {
0%,
100% {
transform: scale(1) translateY(0);
}
50% {
transform: scale(0.985) translateY(2px);
}
}
@keyframes sagi-enter {
from {
opacity: 0;
transform: scale(0.6) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes sagi-error-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
/* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] {
animation: sagi-breathe 4s ease-in-out infinite;
}
/* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see the CSS-blink
note in SagiAvatar.tsx). */
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
animation: sagi-blink 5s ease-in-out infinite;
}
/* happy: whole-mascot bob + twinkling sparkle */
.sagi-avatar[data-mood="happy"] {
animation: sagi-bob 0.5s ease-in-out 0s 4;
}
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
}
/* worried: alarmed shake */
.sagi-avatar[data-mood="worried"] {
animation: sagi-shake 0.35s ease-in-out 0s 3;
}
/* stuck: slow, drooping breathe (fatigued) */
.sagi-avatar[data-mood="stuck"] {
animation: sagi-droop 2.4s ease-in-out infinite;
}
/* thinking: rocking head tilt */
.sagi-avatar[data-mood="thinking"] {
animation: sagi-rock 1.8s ease-in-out infinite;
}
/* watching: attentive sway while a session is live */
.sagi-avatar[data-mood="watching"] {
animation: sagi-sway 3.2s ease-in-out infinite;
}
/* sleeping: slow breathe, droop */
.sagi-avatar[data-mood="sleeping"] {
animation: sagi-breathe 5s ease-in-out infinite;
}
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
Shared by Sagi's own art and an uploaded still image. ── */
.sagi-avatar[data-moment="jump"],
.sagi-pet-image[data-moment="jump"] {
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
}
.sagi-avatar[data-moment="wave"],
.sagi-pet-image[data-moment="wave"] {
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
}
.sagi-avatar[data-moment="fail"],
.sagi-pet-image[data-moment="fail"] {
animation: sagi-shake 0.35s ease-in-out 0s 5;
}
.sagi-avatar[data-moment="wait"],
.sagi-pet-image[data-moment="wait"] {
animation: sagi-tap 0.4s ease-in-out 0s 5;
}
.sagi-avatar[data-moment="review"],
.sagi-pet-image[data-moment="review"] {
animation: sagi-peek 2.2s ease-in-out 0s 1;
}
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 797px 274px;
}
@keyframes sagi-hop {
0%,
100% {
transform: translateY(0) scale(1, 1);
}
15% {
transform: translateY(0) scale(1.08, 0.92);
}
45% {
transform: translateY(-14%) scale(0.96, 1.05);
}
75% {
transform: translateY(0) scale(1.04, 0.96);
}
}
@keyframes sagi-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-9deg);
}
75% {
transform: rotate(9deg);
}
}
@keyframes sagi-tap {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4%);
}
}
@keyframes sagi-peek {
0%,
100% {
transform: rotate(0deg) translateX(0);
}
25%,
40% {
transform: rotate(-7deg) translateX(-4%);
}
60%,
75% {
transform: rotate(7deg) translateX(4%);
}
}
/* ── reduced motion: kill all continuous animation ── */
.sagi-avatar[data-reduced="1"],
.sagi-avatar[data-reduced="1"] * {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
.sagi-avatar,
.sagi-avatar * {
animation: none !important;
}
.sagi-avatar-btn,
.sagi-error-dot,
.sagi-bubble-enter {
animation: none !important;
}
.sagi-avatar-btn,
.sagi-flyout {
transition: none !important;
}
}
/* ════════ shell (avatar button, bubble, panel) ════════ */
/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag
hook. It docks to an edge (AssistiveTouch-style) and remembers where. */
.sagi-avatar-btn {
position: fixed;
z-index: 41; /* above the flyout */
background: transparent;
border: none;
padding: 0;
line-height: 0;
border-radius: 9999px;
cursor: grab;
touch-action: none; /* pointer drives the drag instead of scrolling on touch */
/* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.15s ease;
animation: sagi-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.sagi-avatar-btn:hover {
transform: scale(1.06);
}
.sagi-avatar-btn:active {
transform: scale(0.94);
}
.sagi-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.sagi-avatar-btn[data-dragging="1"] {
cursor: grabbing;
transition: transform 0.15s ease; /* no left/top easing while dragging */
}
.sagi-avatar-btn[data-dragging="1"]:hover {
transform: scale(1.1);
}
/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its
left/top are computed and set inline by SagiFlyout so it never crops. */
.sagi-flyout {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
gap: 0.5rem;
/* Same curve/duration as .sagi-avatar-btn so the bubble travels *with* the
mascot when it snaps to an edge instead of teleporting ahead of it. */
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.sagi-flyout[data-dragging="1"] {
transition: none; /* 1:1 with the pointer while dragging */
}
.sagi-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.sagi-error-dot {
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
border-radius: 9999px;
background: #ef4444;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
animation: sagi-error-pulse 1.2s ease-in-out infinite;
}
/* Sessions waiting on the user, when none errored: the same dot, amber. */
.sagi-error-dot[data-kind="waiting"] {
background: #f59e0b;
animation: none;
}
.sagi-pet-image {
transform-origin: 50% 92%;
}
.sagi-bubble {
max-width: 16rem;
background: #1a1a28;
border: 1px solid #363650;
color: #e8e8f0;
font-size: 0.8125rem;
line-height: 1.2rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-bottom-right-radius: 0.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.sagi-bubble-enter {
animation: sagi-bubble-in 0.22s ease-out;
}
.sagi-bubble[data-tone="error"] {
border-color: rgba(239, 68, 68, 0.6);
}
.sagi-bubble[data-tone="waiting"] {
border-color: rgba(245, 158, 11, 0.6);
}
/* The session(s) a bubble is about, and the button that opens it. */
.sagi-bubble-detail {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1rem;
color: #a8a8c0;
}
.sagi-bubble-subject {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sagi-bubble-open {
flex-shrink: 0;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: rgba(139, 92, 246, 0.2);
color: #c4b5fd;
font-weight: 500;
transition: background 0.15s;
}
.sagi-bubble-open:hover {
background: rgba(139, 92, 246, 0.35);
}
@keyframes sagi-bubble-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sagi-bubble.sagi-no-anim {
animation: none;
}
/* ════════ a custom pet (PetSprite.tsx) ════════ */
/* An uploaded still image has no frames of its own: it bobs, faster while an
agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image {
animation: sagi-pet-bob 2.6s ease-in-out infinite;
-webkit-user-drag: none;
}
.sagi-pet-image[data-mood="thinking"] {
animation-duration: 0.9s;
}
.sagi-pet-image[data-mood="sleeping"] {
animation-duration: 5s;
}
@keyframes sagi-pet-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4%);
}
}
@media (prefers-reduced-motion: reduce) {
.sagi-pet-image {
animation: none;
}
}
/* The pet menu may overlap the mascot; it sits above it while open. */
.sagi-flyout[data-front="1"] {
z-index: 42;
}
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
/* The reference's soft gradient, in the theme's own accent rather than a fixed
colour: a tint of the accent at the top fading into the surface, so it
reads in every light and dark theme. Replies sit on the plain surface like
the reference's white bubbles; the user's own are accent-solid (white text
is only ever on accent-solid). */
.pet-chat {
background: linear-gradient(
180deg,
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
rgb(var(--surface-1)) 100%
);
}
.pet-chat-head {
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
}
.pet-chat-reply {
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
}
.pet-chat-tools {
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
}
.pet-chat-foot {
background: rgb(var(--surface-0));
border-top: 1px solid rgb(var(--border) / 0.6);
}
/* Three bouncing dots while a reply is written. */
.pet-chat-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgb(var(--accent));
opacity: 0.5;
animation: pet-chat-dot 1.2s ease-in-out infinite;
}
.pet-chat-dot:nth-child(2) {
animation-delay: 0.15s;
}
.pet-chat-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pet-chat-dot {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 0.9;
}
}
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
does not shrink the pane to fit it) and a non-zero box (so the paste
registry counts it as on screen). */
.pet-chat-term {
position: fixed;
left: -10000px;
top: 0;
width: 760px;
height: 460px;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.pet-chat-dot {
animation: none;
}
}
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
/* Hearts float up from the pet and its line shows beside it, inside the
avatar button — so a pat reads even while the panel or the chat is open
(the speech bubble hides then). */
.sagi-reaction {
position: absolute;
inset: 0;
pointer-events: none;
}
.sagi-hearts {
position: absolute;
left: 50%;
top: 10%;
display: flex;
gap: 2px;
transform: translateX(-50%);
font-size: 14px;
line-height: 1;
}
.sagi-hearts > span {
animation: sagi-heart-up 1.6s ease-out forwards;
opacity: 0;
}
.sagi-hearts > span:nth-child(2) {
animation-delay: 0.15s;
font-size: 18px;
}
.sagi-hearts > span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes sagi-heart-up {
0% {
opacity: 0;
transform: translateY(6px) scale(0.6);
}
20% {
opacity: 1;
transform: translateY(-4px) scale(1);
}
100% {
opacity: 0;
transform: translateY(-34px) scale(0.9);
}
}
/* The line sits on the side away from the screen edge the pet docks to. */
.sagi-reaction-text {
position: absolute;
top: 30%;
white-space: nowrap;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
color: rgb(var(--fg-primary));
font-size: 0.75rem;
line-height: 1.1rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
animation: sagi-reaction-in 2.4s ease-out forwards;
}
.sagi-reaction-text[data-side="right"] {
right: calc(100% + 6px);
}
.sagi-reaction-text[data-side="left"] {
left: calc(100% + 6px);
}
@keyframes sagi-reaction-in {
0% {
opacity: 0;
transform: scale(0.85);
}
10%,
80% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sagi-hearts > span,
.sagi-reaction-text {
animation: none;
opacity: 1;
}
}