Add unified pet status bubbles

This commit is contained in:
Alvin Unreal 2026-05-13 18:54:24 +02:00
parent aa21ed41ce
commit 13d525fb87
4 changed files with 150 additions and 42 deletions

View file

@ -3,13 +3,16 @@ import { BrowserWindow } from "electron";
import { getAppStateSnapshot } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
const agentPetWindows = new Map<string, BrowserWindow>();
const transientDisplays = new Map<string, PetTransientDisplay>();
const statusBadges = new Map<string, PetStatusBadgeReaction>();
const transientTimers = new Map<string, NodeJS.Timeout>();
const transientAnimationTimers = new Map<string, NodeJS.Timeout>();
const statusBadgeTimers = new Map<string, NodeJS.Timeout>();
const dismissedAgentPets = new Set<string>();
const busyStatusBadgeMs = 120_000;
export function showAgentPet(petId: string): boolean {
if (dismissedAgentPets.has(petId)) return false;
@ -49,6 +52,7 @@ export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction)
}
export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
if (!reaction) clearStatusBadge(petId);
setAgentDisplay(petId, { message, reaction });
const shown = showAgentPet(petId);
return shown ? { shown } : { shown, reason: "dismissed" };
@ -63,7 +67,7 @@ export function closeAllAgentPets(): void {
export function refreshAgentPetContent(): void {
for (const [petId, window] of agentPetWindows.entries()) {
if (!window.isDestroyed()) {
void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null);
void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null, statusBadges.get(petId) ?? null);
}
}
}
@ -81,6 +85,7 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
displayName: pet.displayName,
position: { x: initial.x - offset * 36, y: initial.y - offset * 24 },
display: transientDisplays.get(petId) ?? null,
badge: statusBadges.get(petId) ?? null,
onCloseRequested: () => dismissAgentPetForActiveLease(petId),
});
@ -95,6 +100,7 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, display);
transientDisplays.set(petId, preparedDisplay);
if (display.reaction) setStatusBadge(petId, display.reaction);
const existingTimer = transientTimers.get(petId);
if (existingTimer) clearTimeout(existingTimer);
const existingAnimationTimer = transientAnimationTimers.get(petId);
@ -121,11 +127,11 @@ function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
if (animationTimer) clearTimeout(animationTimer);
transientAnimationTimers.delete(petId);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null, statusBadges.get(petId) ?? null);
}, transientDisplayMs);
transientTimers.set(petId, timer);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay, statusBadges.get(petId) ?? null);
}
function clearAgentDisplay(petId: string): void {
@ -135,5 +141,37 @@ function clearAgentDisplay(petId: string): void {
if (animationTimer) clearTimeout(animationTimer);
transientTimers.delete(petId);
transientAnimationTimers.delete(petId);
const badgeTimer = statusBadgeTimers.get(petId);
if (badgeTimer) clearTimeout(badgeTimer);
statusBadgeTimers.delete(petId);
transientDisplays.delete(petId);
statusBadges.delete(petId);
}
function setStatusBadge(petId: string, reaction: OpenPetsReaction): void {
if (reaction === "idle") {
clearStatusBadge(petId);
return;
}
statusBadges.set(petId, reaction);
const existingTimer = statusBadgeTimers.get(petId);
if (existingTimer) clearTimeout(existingTimer);
const timer = setTimeout(() => {
clearStatusBadge(petId);
const window = agentPetWindows.get(petId);
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null, null);
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
statusBadgeTimers.set(petId, timer);
}
function clearStatusBadge(petId: string): void {
statusBadges.delete(petId);
const timer = statusBadgeTimers.get(petId);
if (timer) clearTimeout(timer);
statusBadgeTimers.delete(petId);
}
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}

View file

@ -132,8 +132,8 @@ assert.match(windowsSource, /pm-sprite-state-happy\.pm-animate-sprite \{ animati
assert.match(windowsSource, /@keyframes pm-sprite-thinking/, "Pet Manager must define thinking row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-wave/, "Pet Manager must define wave row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-happy/, "Pet Manager must define happy row animation keyframes.");
assert.match(petWindowSource, /max-width:\s*min\(204px/, "very long message bubbles must stay capped within the tight pet window.");
assert.match(petWindowSource, /-webkit-line-clamp:\s*9/, "very long message bubbles must allow enough visible lines.");
assert.match(petWindowSource, /max-width:\s*min\(220px/, "very long message bubbles must stay capped within the tight pet window.");
assert.match(petWindowSource, /-webkit-line-clamp:\s*8/, "very long message bubbles must allow enough visible lines.");
assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state.");
assert.match(petWindowSource, /createSpriteStateCss\("\.installed-sprite"\)/, "installed sprite CSS must react to reaction state.");
assert.match(petWindowSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state.");

View file

@ -3,13 +3,16 @@ import { BrowserWindow, screen } from "electron";
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
let defaultPetWindow: BrowserWindow | null = null;
let paused = false;
let transientDisplay: PetTransientDisplay | null = null;
let statusBadge: PetStatusBadgeReaction | null = null;
let transientDisplayTimeout: NodeJS.Timeout | null = null;
let transientAnimationTimeout: NodeJS.Timeout | null = null;
let statusBadgeTimeout: NodeJS.Timeout | null = null;
const busyStatusBadgeMs = 120_000;
export function showDefaultPet(): void {
updatePreferences({ openDefaultPetOnLaunch: true });
@ -44,7 +47,7 @@ export function setDefaultPetPaused(nextPaused: boolean): void {
return;
}
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge);
}
export function getDefaultPetPaused(): boolean {
@ -56,7 +59,7 @@ export function refreshDefaultPetContent(): void {
return;
}
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge);
}
export function applyExternalPetReaction(reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
@ -74,6 +77,7 @@ export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction
return { shown: false, reason: "paused" };
}
if (!reaction) clearStatusBadge();
setTransientDisplay({ message, reaction });
showDefaultPetForExternalEvent();
return { shown: isDefaultPetVisible() };
@ -108,6 +112,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
position,
paused,
display: transientDisplay,
badge: statusBadge,
onPositionChanged: setDefaultPetPosition,
onHideRequested: hideDefaultPet,
});
@ -121,6 +126,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
function setTransientDisplay(display: PetTransientDisplay): void {
transientDisplay = mergePetTransientDisplay(transientDisplay, display);
if (display.reaction) setStatusBadge(display.reaction);
if (transientDisplayTimeout) {
clearTimeout(transientDisplayTimeout);
@ -160,6 +166,30 @@ function showDefaultPetForExternalEvent(): void {
}
}
function setStatusBadge(reaction: OpenPetsReaction): void {
if (reaction === "idle") {
clearStatusBadge();
return;
}
statusBadge = reaction;
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = setTimeout(() => {
clearStatusBadge();
refreshDefaultPetContent();
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
}
function clearStatusBadge(): void {
statusBadge = null;
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
statusBadgeTimeout = null;
}
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
}
function reclampDefaultPetWindow(): void {
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
return;

View file

@ -14,6 +14,7 @@ export interface DefaultPetWindowOptions {
readonly position: Point;
readonly paused: boolean;
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly onPositionChanged: (position: Point) => void;
readonly onHideRequested: () => void;
}
@ -23,6 +24,7 @@ export interface AgentPetWindowOptions {
readonly displayName: string;
readonly position: Point;
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly onCloseRequested: () => void;
}
@ -32,6 +34,8 @@ export interface PetTransientDisplay {
readonly reactionMessage?: string;
}
export type PetStatusBadgeReaction = Exclude<OpenPetsReaction, "idle">;
type PetMotionState = "idle" | "run-left" | "run-right";
type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review";
@ -94,7 +98,7 @@ export function createDefaultPetWindow(options: DefaultPetWindowOptions): Browse
options.onPositionChanged(readWindowPosition(window));
});
void loadDefaultPetContent(window, options.paused, options.display);
void loadDefaultPetContent(window, options.paused, options.display, options.badge);
return window;
}
@ -104,7 +108,7 @@ export function createAgentPetWindow(options: AgentPetWindowOptions): BrowserWin
installMousePassthroughAndDrag(window);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
void loadExplicitPetContent(window, options.petId, options.display);
void loadExplicitPetContent(window, options.petId, options.display, options.badge);
return window;
}
@ -241,21 +245,21 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void {
}
}
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null): Promise<void> {
const html = await createDefaultPetHtml(paused, display);
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise<void> {
const html = await createDefaultPetHtml(paused, display, badge);
await loadPetHtmlFile(window, html, "default").catch((error: unknown) => {
console.error("Failed to load default pet URL.", error);
});
}
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null): Promise<void> {
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise<void> {
try {
const state = getAppStateSnapshot();
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet || pet.broken || pet.id === builtInPet.id) {
throw new Error(`Cannot render explicit pet: ${petId}`);
}
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue);
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue, badge);
await loadPetHtmlFile(window, html, `explicit-${pet.id}`);
} catch (error: unknown) {
console.error(`Failed to load explicit pet ${petId} URL.`, error);
@ -299,14 +303,14 @@ export function readWindowPosition(window: BrowserWindow): Point {
return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize);
}
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string> {
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display);
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise<string> {
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display, badge);
if (installedPetHtml) {
return installedPetHtml;
}
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
const bubble = createBubbleMarkup(display, paused);
const bubble = createBubbleMarkup(display, paused, badge);
const stateRows = defaultPetSprite.states;
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
@ -353,7 +357,7 @@ async function createDefaultPetHtml(paused: boolean, display: PetTransientDispla
</html>`;
}
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string | null> {
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise<string | null> {
const state = getAppStateSnapshot();
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
@ -362,7 +366,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD
}
try {
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue);
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge);
} catch (error) {
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
try {
@ -374,7 +378,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD
}
}
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue): Promise<string> {
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null): Promise<string> {
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
@ -382,7 +386,7 @@ async function createInstalledPetHtml(petId: string, displayName: string, paused
}
const imageUrl = pathToFileURL(spritesheetPath).toString();
const bubble = createBubbleMarkup(display, paused);
const bubble = createBubbleMarkup(display, paused, badge);
const stateRows = defaultPetSprite.states;
return `<!doctype html>
@ -440,25 +444,38 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
const petBottom = 22;
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 3);
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
return `
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
html { color: #172033; }
body { -webkit-app-region: no-drag; pointer-events: none; }
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 2; box-sizing: border-box; max-width: min(200px, calc(100vw - 14px)); max-height: 92px; padding: 8px 10px; background: #fff; color: #000; font: 700 11px/14px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: left; border: 2px solid #000; border-radius: 4px; box-shadow: 2px 2px 0 #000; white-space: normal; overflow-wrap: anywhere; word-break: normal; overflow: hidden; pointer-events: auto; -webkit-app-region: no-drag; transform: translateX(-50%); opacity: 1; animation: bubble-in 160ms steps(2, end); }
.bubble-text { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; }
.bubble::after, .bubble::before { content: none; }
.bubble.is-reaction { max-width: min(142px, calc(100vw - 10px)); max-height: 64px; padding: 6px 8px; font: 750 11px/13px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: center; overflow-wrap: break-word; }
.bubble.is-reaction .bubble-text { -webkit-line-clamp: 3; }
.bubble.is-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 106px; font-size: 10.5px; line-height: 13px; }
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 7; }
.bubble.is-very-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 126px; font-size: 10px; line-height: 13px; }
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 9; }
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(3px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble { animation: none !important; } }
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; z-index: 1; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(220px, calc(100vw - 18px)); max-height: 128px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: blur(10px); transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
.bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); }
.bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; }
.bubble-status-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 16px; height: 16px; border-radius: 999px; background: #3b82f6; color: #fff; font-size: 10px; line-height: 1; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.3); }
.bubble-status-icon::before { content: attr(data-icon); }
.bubble-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bubble-divider { height: 1px; width: 100%; margin: 8px 0; background: rgba(30, 58, 138, 0.12); }
.bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.bubble-text { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-wrap: normal; overflow-wrap: break-word; }
.bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; }
.bubble.is-message-only { border-radius: 14px 14px 3px 14px; }
.bubble.is-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 138px; }
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 6; font-size: 10px; line-height: 13px; }
.bubble.is-very-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 156px; }
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 8; font-size: 9.5px; line-height: 12.5px; }
.bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); }
.bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); }
.bubble.is-success .bubble-status-icon { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); }
.bubble.is-error .bubble-status-icon { background: #ef4444; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(239, 68, 68, 0.34); }
.bubble.is-info .bubble-status-icon { background: #38bdf8; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(56, 189, 248, 0.34); }
.bubble.is-busy .bubble-status-icon::before { content: ""; display: block; width: 7px; height: 7px; border-radius: 999px; background: #fff; animation: status-pulse 820ms ease-in-out infinite; }
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.96); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
@keyframes status-pulse { 0%, 100% { transform: scale(0.72); opacity: 0.58; } 50% { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } }
`;
}
@ -480,17 +497,40 @@ function getReactionSpriteState(reaction: OpenPetsReaction | undefined): Univers
return reaction ? reactionToSpriteState[reaction] : "idle";
}
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean): string {
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null): string {
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
if (!text) return "";
const className = getBubbleClassName(text, Boolean(display?.message && !display?.reactionMessage));
return `<div class="${className}" role="status" aria-live="polite"><span class="bubble-text">${escapeHtml(text)}</span></div>`;
const status = !paused && badgeReaction ? getStatusBadge(badgeReaction) : null;
if (!text && !status) return "";
const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage);
const className = getBubbleClassName(text, isExplicitMessage, status?.className);
const header = status ? `<div class="bubble-header"><span class="bubble-status-icon" data-icon="${escapeHtml(status.icon)}" aria-hidden="true"></span><span class="bubble-status-label">${escapeHtml(status.label)}</span></div>` : "";
const divider = status && text ? `<div class="bubble-divider" aria-hidden="true"></div>` : "";
const body = text ? `<div class="bubble-body"><span class="bubble-text">${escapeHtml(text)}</span></div>` : "";
return `<div class="${className}" role="status" aria-live="polite">${header}${divider}${body}</div>`;
}
function getBubbleClassName(text: string, isExplicitMessage: boolean): string {
if (!isExplicitMessage) return "bubble is-reaction";
function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon: string; readonly label: string } | null {
if (reaction === "thinking") return { className: "is-busy", icon: "", label: "Thinking" };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: "Working" };
if (reaction === "editing") return { className: "is-busy", icon: "", label: "Editing" };
if (reaction === "testing") return { className: "is-busy", icon: "", label: "Testing" };
if (reaction === "waiting") return { className: "is-waiting", icon: "◷", label: "Waiting" };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", icon: "✓", label: "Done" };
if (reaction === "error") return { className: "is-error", icon: "!", label: "Oops" };
if (reaction === "waving") return { className: "is-info", icon: "♪", label: "Hi" };
return null;
}
function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined): string {
const statusClass = statusClassName ? ` ${statusClassName}` : "";
if (!text) return `bubble is-status-only${statusClass}`;
if (!statusClassName) return `bubble is-message-only${isExplicitMessage ? getBubbleLengthClass(text) : ""}`;
const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
return `bubble is-message${lengthClass}`;
return `bubble is-message${statusClass}${lengthClass}`;
}
function getBubbleLengthClass(text: string): string {
return text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
}
function escapeHtml(value: string): string {