From 13d525fb87b35b07b5f597e204ec31b43c9e1d48 Mon Sep 17 00:00:00 2001 From: Alvin Unreal Date: Wed, 13 May 2026 18:54:24 +0200 Subject: [PATCH] Add unified pet status bubbles --- apps/desktop/src/agent-pet-controller.ts | 46 +++++++- apps/desktop/src/check-packaging-contract.ts | 4 +- apps/desktop/src/default-pet-controller.ts | 36 ++++++- apps/desktop/src/pet-window.ts | 106 +++++++++++++------ 4 files changed, 150 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/agent-pet-controller.ts b/apps/desktop/src/agent-pet-controller.ts index 03ffbfc0..1b8af04c 100644 --- a/apps/desktop/src/agent-pet-controller.ts +++ b/apps/desktop/src/agent-pet-controller.ts @@ -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(); const transientDisplays = new Map(); +const statusBadges = new Map(); const transientTimers = new Map(); const transientAnimationTimers = new Map(); +const statusBadgeTimers = new Map(); const dismissedAgentPets = new Set(); +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"; } diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index e863cefa..d51b9776 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -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."); diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts index 5cd36ecf..12334521 100644 --- a/apps/desktop/src/default-pet-controller.ts +++ b/apps/desktop/src/default-pet-controller.ts @@ -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; diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index 79396ec6..90cbb958 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -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; + 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 { - const html = await createDefaultPetHtml(paused, display); +export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise { + 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 { +export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise { 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 { - const installedPetHtml = await tryCreateInstalledPetHtml(paused, display); +async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { + 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 `; } -async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise { +async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { 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 { +async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null): Promise { 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 ` @@ -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 `
${escapeHtml(text)}
`; + 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 ? `
${escapeHtml(status.label)}
` : ""; + const divider = status && text ? `` : ""; + const body = text ? `
${escapeHtml(text)}
` : ""; + return `
${header}${divider}${body}
`; } -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 {