From 931272529e6001ac06760c7cce5508840c15eb97 Mon Sep 17 00:00:00 2001 From: Stsirywtbd <1660237548@qq.com> Date: Sun, 1 Nov 2026 21:30:52 +0800 Subject: [PATCH 1/4] sync: groklive trial changes for private mirror --- .gitignore | 6 + scripts/frontend-color-surface-registry.json | 20 +- .../grok-live/preview.svg | 5 + .../AgentCompanionDesktopPet.tsx | 31 +- .../pages/application/PetSettingsSection.tsx | 56 +- .../AgentCompanionPet.appearance.ts | 2 +- .../components/AgentCompanionPet.tsx | 10 +- .../src/flow_chat/components/GrokLivePet.scss | 41 + .../flow_chat/components/GrokLivePet.test.tsx | 139 + .../src/flow_chat/components/GrokLivePet.tsx | 181 + .../flow_chat/components/grok-live/SOURCE.txt | 5 + .../components/grok-live/grok-player.d.ts | 27 + .../components/grok-live/grok-player.js | 1931 ++++ .../components/grok-live/grok-recipes.json | 8309 +++++++++++++++++ .../components/grok-live/grokLiveSettings.ts | 62 + .../services/AgentCompanionPetService.ts | 12 + .../config/services/grokLivePetIdentity.ts | 6 + .../src/locales/en-US/settings/runtime.json | 11 + .../src/locales/zh-CN/settings/runtime.json | 11 + .../src/locales/zh-TW/settings/runtime.json | 11 + 20 files changed, 10864 insertions(+), 12 deletions(-) create mode 100644 src/web-ui/public/agent-companion-pets/grok-live/preview.svg create mode 100644 src/web-ui/src/flow_chat/components/GrokLivePet.scss create mode 100644 src/web-ui/src/flow_chat/components/GrokLivePet.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/GrokLivePet.tsx create mode 100644 src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt create mode 100644 src/web-ui/src/flow_chat/components/grok-live/grok-player.d.ts create mode 100644 src/web-ui/src/flow_chat/components/grok-live/grok-player.js create mode 100644 src/web-ui/src/flow_chat/components/grok-live/grok-recipes.json create mode 100644 src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts create mode 100644 src/web-ui/src/infrastructure/config/services/grokLivePetIdentity.ts diff --git a/.gitignore b/.gitignore index a5fa33ceee..415b34cad7 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,9 @@ src/apps/mobile/android/.kotlin/ /docs/superpowers/ AGENTS.override.md + +# CodeGraph index (local, large) +.codegraph/ + +# Desktop test artifacts +target-desktop-test/ diff --git a/scripts/frontend-color-surface-registry.json b/scripts/frontend-color-surface-registry.json index ca3a32bc3e..dcfa2848c9 100644 --- a/scripts/frontend-color-surface-registry.json +++ b/scripts/frontend-color-surface-registry.json @@ -55,9 +55,11 @@ "src/flow_chat/assets/subagent-avatars/robot-18.svg", "src/flow_chat/assets/subagent-avatars/robot-19.svg", "src/flow_chat/assets/subagent-avatars/robot-20.svg", - "src/tools/generative-ui/components/openui", +"src/tools/generative-ui/components/openui", "src/tools/generative-ui/components/openuiLibrary.tsx", - "src/tools/generative-ui/components/vendor" + "src/tools/generative-ui/components/vendor", + "public/agent-companion-pets/grok-live/preview.svg", + "src/flow_chat/components/grok-live/grok-player.js" ] } }, @@ -525,6 +527,20 @@ } ], "exclusions": [ + { + "id": "grok-live-thumbnail", + "path": "src/web-ui/public/agent-companion-pets/grok-live/preview.svg", + "kind": "authored-vector-artwork", + "owner": "GrokLive preview in AgentCompanionPetService", + "reason": "The transparent pet picker artwork requires an invariant black body and two opaque white eyes; it is an illustration, not theme-dependent UI chrome." + }, + { + "id": "grok-live-vendor-mask", + "path": "src/web-ui/src/flow_chat/components/grok-live/grok-player.js", + "kind": "vendored-renderer", + "owner": "src/web-ui/src/flow_chat/components/grok-live/SOURCE.txt", + "reason": "The local animation renderer needs fixed black and white mask-pixel semantics; this narrowly scoped vendor file is not product UI chrome." + }, { "id": "subagent-hatch-artwork", "path": "design-system/packages/ui/src/brand/assets/subagent-egg.svg", diff --git a/src/web-ui/public/agent-companion-pets/grok-live/preview.svg b/src/web-ui/public/agent-companion-pets/grok-live/preview.svg new file mode 100644 index 0000000000..03622f15cb --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/grok-live/preview.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index ac62029043..2a95c7210d 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -29,6 +29,7 @@ import { createLogger } from '@/shared/utils/logger'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; import { getPetLookDirection } from '@/infrastructure/config/services/agentCompanionPetSprite'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; import { startAgentCompanionDrag } from '@/infrastructure/config/services/AgentCompanionDragService'; import { prepareAgentCompanionPointerDrag, type CompanionPointerDrag } from '@/infrastructure/config/services/AgentCompanionPointerDragService'; import { createCompanionWindowResizer, isCompanionViewportShort } from './agentCompanionWindowResize'; @@ -192,9 +193,11 @@ export const AgentCompanionDesktopPet: React.FC = () => { const [typedOutputBySessionId, setTypedOutputBySessionId] = useState>({}); const [isHoveringPet, setIsHoveringPet] = useState(false); const [lookDirection, setLookDirection] = useState(null); + const [grokGaze, setGrokGaze] = useState<[number, number] | null>(null); const trackPetLook = mood === 'rest' && pet != null && ( pet.spriteVersionNumber === 2 || (pet.source === 'user' && pet.spriteVersionNumber == null) ); + const trackGrokGaze = isGrokLivePet(pet); const [isDraggingPet, setIsDraggingPet] = useState(false); const [dragDirection, setDragDirection] = useState<'left' | 'right'>('right'); const stopDragRef = useRef<(() => void) | null>(null); @@ -238,9 +241,11 @@ export const AgentCompanionDesktopPet: React.FC = () => { const displayTasks = [...visibleTasks].reverse(); const activePetSize = pet && petFrameSize ? petFrameSize - : pet - ? DEFAULT_PETDEX_DISPLAY_SIZE - : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; + : pet && isGrokLivePet(pet) + ? { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE } + : pet + ? DEFAULT_PETDEX_DISPLAY_SIZE + : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; useEffect(() => { let disposed = false; @@ -539,7 +544,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { }, [windowResizer]); useEffect(() => { - if (IS_WINDOWS_WEBVIEW && !trackPetLook) { + if (IS_WINDOWS_WEBVIEW && !trackPetLook && !trackGrokGaze) { return; } @@ -656,6 +661,17 @@ export const AgentCompanionDesktopPet: React.FC = () => { lastPointerMovedAt = now; lastPointer = { x: pointer.x, y: pointer.y }; } + const gazeX = cachedHitboxRect && !isReducedMotionPreferred() + ? Math.max(-1, Math.min(1, (pointerX - (cachedHitboxRect.left + cachedHitboxRect.width / 2)) / (cachedHitboxRect.width * 2))) + : 0; + const gazeY = cachedHitboxRect && !isReducedMotionPreferred() + ? Math.max(-1, Math.min(1, -(pointerY - (cachedHitboxRect.top + cachedHitboxRect.height / 2)) / (cachedHitboxRect.height * 2))) + : 0; + if (trackGrokGaze) { + setGrokGaze(previous => previous?.[0] === gazeX && previous?.[1] === gazeY ? previous : [gazeX, gazeY]); + } else { + setGrokGaze(null); + } setLookDirection(cachedHitboxRect && now - lastPointerMovedAt < PET_LOOK_HOLD_MS && !isReducedMotionPreferred() ? getPetLookDirection( pointerX - (cachedHitboxRect.left + cachedHitboxRect.width / 2), @@ -689,7 +705,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { if (document.visibilityState === 'hidden') return; // Keep the same visible hover latency, and refresh immediately on return. void pollPointerHover(); - intervalId = window.setInterval(() => { void pollPointerHover(); }, POINTER_HOVER_POLL_INTERVAL_MS); + intervalId = window.setInterval(() => { void pollPointerHover(); }, trackGrokGaze ? 33 : POINTER_HOVER_POLL_INTERVAL_MS); }; document.addEventListener('visibilitychange', syncPointerPolling); syncPointerPolling(); @@ -702,7 +718,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { removeWindowMovedListener?.(); removeScaleChangedListener?.(); }; - }, [trackPetLook]); + }, [trackPetLook, trackGrokGaze]); const showMainWindowFromPet = useCallback(async () => { try { @@ -1251,12 +1267,15 @@ export const AgentCompanionDesktopPet: React.FC = () => { data-openbitfun-component="agent-companion-desktop-pet" data-openbitfun-part="hitbox" data-openbitfun-state={hasAttentionTask ? 'attention' : undefined}> task.state === 'attention')} + toolWaiting={tasks.some(task => task.state === 'waiting')} dragDirection={dragDirection} action={!isDraggingPet ? mood === 'rest' && visibleTasks.some(task => task.state === 'error') ? 'failed' : reaction?.action ?? null : null} pet={pet} lookDirection={trackPetLook && !isDraggingPet ? lookDirection : null} + gaze={trackGrokGaze && !isDraggingPet ? grokGaze : null} nativePetdexSize petdexScale={PETDEX_DESKTOP_SCALE} petdexMaxSize={PET_MAX_DISPLAY_SIZE} diff --git a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx index 12d9cc61e2..7459f88e2f 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx @@ -17,6 +17,8 @@ import { type AgentCompanionPetPackage, } from '@/infrastructure/config/services/AgentCompanionPetService'; import { getPetSpriteLayout } from '@/infrastructure/config/services/agentCompanionPetSprite'; +import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { DEFAULT_GROK_LIVE_SETTINGS, readGrokLiveSettings, saveGrokLiveSettings, subscribeGrokLiveSettings, type GrokLiveSettings } from '@/flow_chat/components/grok-live/grokLiveSettings'; import { aiExperienceConfigService, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; import { useI18n } from '@/infrastructure/i18n'; @@ -28,6 +30,7 @@ import { captureConfirmationAnchor, Icon, IconButton, OverflowText, + Select, Switch, Tooltip } from '@openbitfun/ui'; @@ -39,9 +42,10 @@ const IS_TAURI_DESKTOP = typeof window !== 'undefined' && '__TAURI__' in window; function getPetPreviewStyle(pet: AgentCompanionPetPackage): React.CSSProperties { return { - imageRendering: pet.source === 'preset' && pet.id === 'bitblob' ? 'auto' : undefined, + imageRendering: isGrokLivePet(pet) || (pet.source === 'preset' && pet.id === 'bitblob') ? 'auto' : undefined, '--openbitfun-pet-preview-src': `url("${pet.previewSrc}")`, - backgroundSize: `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, + backgroundSize: isGrokLivePet(pet) ? 'contain' : `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, + ...(isGrokLivePet(pet) ? { backgroundPosition: 'center', animation: 'none' } : {}), } as React.CSSProperties; } @@ -70,6 +74,16 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true const [companionPetImporting, setCompanionPetImporting] = useState(false); const [companionPetDeletingPath, setCompanionPetDeletingPath] = useState(null); + const [grokSettings, setGrokSettings] = useState(readGrokLiveSettings); + useEffect(() => subscribeGrokLiveSettings(() => setGrokSettings(readGrokLiveSettings())), []); + const updateGrok = (patch: Partial) => { + try { + setGrokSettings(saveGrokLiveSettings(patch)); + } catch (error) { + log.error('Failed to save GrokLive settings', error); + notification.error(t('messages.saveFailed')); + } + }; const reloadCompanionPets = useCallback(async () => { setCompanionPets(await companionPetInventory.read(listAgentCompanionPets)); @@ -388,6 +402,44 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true + {isGrokLivePet(selectedCompanionPetReference) && ( + <> + + + + + updateGrok({ size: Number(event.target.value) })} /> + {grokSettings.size} + + + updateGrok({ bodyColor: value as GrokLiveSettings['bodyColor'] })} + options={[ + { value: 'black', label: t('features.pet.grok.bodyColor.black') }, + { value: 'darkGray', label: t('features.pet.grok.bodyColor.darkGray') }, + { value: 'gray', label: t('features.pet.grok.bodyColor.gray') }, + { value: 'custom', label: t('features.pet.grok.bodyColor.custom') }, + ]} aria-label={t('features.pet.grok.bodyColor')} /> + {grokSettings.bodyColor === 'custom' && ( + updateGrok({ bodyCustomColor: event.target.value })} /> + )} + + + updateGrok({ eyeCustomColor: event.target.value })} /> + )} + {import.meta.env.DEV && ( updateGrok({ showStats: event.target.checked })} /> diff --git a/src/web-ui/src/flow_chat/components/GrokLivePet.tsx b/src/web-ui/src/flow_chat/components/GrokLivePet.tsx index af57c796a0..7116e52e75 100644 --- a/src/web-ui/src/flow_chat/components/GrokLivePet.tsx +++ b/src/web-ui/src/flow_chat/components/GrokLivePet.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import recipes from './grok-live/grok-recipes.json'; import { createGrokBot, type GrokBot } from './grok-live/grok-player.js'; -import { readGrokLiveSettings, subscribeGrokLiveSettings } from './grok-live/grokLiveSettings'; +import { readGrokLiveSettings, subscribeGrokLiveSettings, resolveGrokBodyColorHex, resolveGrokEyeColorHex, hexToRgb01 } from './grok-live/grokLiveSettings'; import type { AgentCompanionPetProps } from './AgentCompanionPet'; import './GrokLivePet.scss'; @@ -79,7 +79,7 @@ export const GrokLivePet: React.FC = ({ bot = createGrokBot(stageRef.current, { data: recipes, size: 320, maxFps, state: baseRef.current, follow: settings.follow && !hasExternalGaze && !reducedMotion, reducedMotion, - theme: { ink: [0, 0, 0], spot: [1, 1, 1], cutout: false }, + theme: { ink: hexToRgb01(resolveGrokBodyColorHex(settings)), spot: hexToRgb01(resolveGrokEyeColorHex(settings)), cutout: false }, onFrame: import.meta.env.DEV && settings.showStats ? sample => { if (sample.intervalMs == null || !Number.isFinite(sample.intervalMs)) return; samples.push(sample); @@ -122,10 +122,14 @@ export const GrokLivePet: React.FC = ({ botRef.current?.setGaze(gaze ?? null); }, [gaze, settings.follow, reducedMotion, hasExternalGaze, settings.fps, settings.showStats]); useEffect(() => { + const ink = hexToRgb01(resolveGrokBodyColorHex(settings)); botRef.current?.setGlow(settings.glow ? { - inner: [.16, .16, .16], mid: [.05, .05, .05], rim: [0, 0, 0], halo: 0, + inner: ink.map(v => Math.min(1, v + 0.16)), + mid: ink.map(v => Math.max(0, v - 0.05)), + rim: ink.map(v => Math.max(0, v - 0.14)), + halo: 0, } : null); - }, [settings.glow, settings.fps, settings.showStats, reducedMotion]); + }, [settings.glow, settings.bodyColor, settings.bodyCustomColor, settings.fps, settings.showStats, reducedMotion]); useEffect(() => { if ((!action || !transientActiveRef.current) && botRef.current) { applyBaseState(botRef.current, baseRef.current, settings.idleMotion); @@ -170,9 +174,9 @@ export const GrokLivePet: React.FC = ({ aria-label={error ? 'GrokLive (animation unavailable)' : 'GrokLive'} role="img">
{!ready && ()}
{import.meta.env.DEV && settings.showStats && stats && ({stats})} diff --git a/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts b/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts index df9956b629..7278c45f46 100644 --- a/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts +++ b/src/web-ui/src/flow_chat/components/grok-live/grokLiveSettings.ts @@ -5,12 +5,49 @@ export interface GrokLiveSettings { idleMotion: boolean; glow: boolean; showStats: boolean; + bodyColor: 'black' | 'darkGray' | 'gray' | 'custom'; + bodyCustomColor: string; + eyeColor: 'white' | 'lightGray' | 'custom'; + eyeCustomColor: string; } export const DEFAULT_GROK_LIVE_SETTINGS: GrokLiveSettings = { size: 96, fps: 'auto', follow: true, idleMotion: true, glow: false, showStats: false, + bodyColor: 'black', bodyCustomColor: '#000000', + eyeColor: 'white', eyeCustomColor: '#ffffff', }; +const BODY_COLOR_PRESETS: Record = { + black: '#000000', darkGray: '#3a3a3a', gray: '#737373', +}; +const EYE_COLOR_PRESETS: Record = { + white: '#ffffff', lightGray: '#d9d9d9', +}; + +export function resolveGrokBodyColorHex(settings: GrokLiveSettings): string { + if (settings.bodyColor === 'custom') return normalizeHex(settings.bodyCustomColor, '#000000'); + return BODY_COLOR_PRESETS[settings.bodyColor] ?? '#000000'; +} + +export function resolveGrokEyeColorHex(settings: GrokLiveSettings): string { + if (settings.eyeColor === 'custom') return normalizeHex(settings.eyeCustomColor, '#ffffff'); + return EYE_COLOR_PRESETS[settings.eyeColor] ?? '#ffffff'; +} + +function normalizeHex(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const v = value.trim(); + return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback; +} + +export function hexToRgb01(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; +} const KEY = 'openbitfun.grok-live.settings.v1'; const EVENT = 'openbitfun:grok-live-settings'; const CHANNEL = 'openbitfun-grok-live-settings'; @@ -26,6 +63,11 @@ export function readGrokLiveSettings(): GrokLiveSettings { idleMotion: typeof saved.idleMotion === 'boolean' ? saved.idleMotion : true, glow: saved.glow === true, showStats: saved.showStats === true, + bodyColor: saved.bodyColor === 'darkGray' || saved.bodyColor === 'gray' || saved.bodyColor === 'custom' + ? saved.bodyColor : 'black', + bodyCustomColor: normalizeHex(saved.bodyCustomColor, '#000000'), + eyeColor: saved.eyeColor === 'lightGray' || saved.eyeColor === 'custom' ? saved.eyeColor : 'white', + eyeCustomColor: normalizeHex(saved.eyeCustomColor, '#ffffff'), }; } catch { return { ...DEFAULT_GROK_LIVE_SETTINGS }; diff --git a/src/web-ui/src/locales/en-US/settings/runtime.json b/src/web-ui/src/locales/en-US/settings/runtime.json index 066085d095..90398915af 100644 --- a/src/web-ui/src/locales/en-US/settings/runtime.json +++ b/src/web-ui/src/locales/en-US/settings/runtime.json @@ -43,7 +43,16 @@ "idleMotion": "Idle motion and blinks", "glow": "Extra glow (may lower FPS)", "showStats": "Debug frame statistics (dev build only)", - "reset": "Restore defaults" + "reset": "Restore defaults", + "bodyColor": "Sphere color", + "bodyColor.black": "Black", + "bodyColor.darkGray": "Dark gray", + "bodyColor.gray": "Gray", + "bodyColor.custom": "Custom", + "eyeColor": "Eye color", + "eyeColor.white": "White", + "eyeColor.lightGray": "Light gray", + "eyeColor.custom": "Custom" }, "presets": { "blueGolden": { diff --git a/src/web-ui/src/locales/zh-CN/settings/runtime.json b/src/web-ui/src/locales/zh-CN/settings/runtime.json index 0bc157b1f4..fff5062c2f 100644 --- a/src/web-ui/src/locales/zh-CN/settings/runtime.json +++ b/src/web-ui/src/locales/zh-CN/settings/runtime.json @@ -43,7 +43,16 @@ "idleMotion": "闲置动作和眨眼", "glow": "额外发光(可能降低帧率)", "showStats": "调试帧统计(仅开发版)", - "reset": "恢复默认" + "reset": "恢复默认", + "bodyColor": "球体颜色", + "bodyColor.black": "黑色", + "bodyColor.darkGray": "深灰色", + "bodyColor.gray": "灰色", + "bodyColor.custom": "自定义", + "eyeColor": "眼睛颜色", + "eyeColor.white": "白色", + "eyeColor.lightGray": "浅灰色", + "eyeColor.custom": "自定义" }, "presets": { "blueGolden": { diff --git a/src/web-ui/src/locales/zh-TW/settings/runtime.json b/src/web-ui/src/locales/zh-TW/settings/runtime.json index 8b079f74e7..aafe1a505d 100644 --- a/src/web-ui/src/locales/zh-TW/settings/runtime.json +++ b/src/web-ui/src/locales/zh-TW/settings/runtime.json @@ -43,7 +43,16 @@ "idleMotion": "閒置動作與眨眼", "glow": "額外發光(可能降低幀率)", "showStats": "除錯幀統計(僅開發版)", - "reset": "恢復預設" + "reset": "恢復預設", + "bodyColor": "球體顏色", + "bodyColor.black": "黑色", + "bodyColor.darkGray": "深灰色", + "bodyColor.gray": "灰色", + "bodyColor.custom": "自訂", + "eyeColor": "眼睛顏色", + "eyeColor.white": "白色", + "eyeColor.lightGray": "淺灰色", + "eyeColor.custom": "自訂" }, "presets": { "blueGolden": { From 86dbba39d7d9bc08808f8cc9779107005c67d332 Mon Sep 17 00:00:00 2001 From: Stsirywtbd <1660237548@qq.com> Date: Fri, 6 Nov 2026 00:24:28 +0800 Subject: [PATCH 3/4] chore: checkpoint living-line changes before 1.0.9 upgrade --- pnpm-workspace.yaml | 7 + src/apps/desktop/src/appearance.rs | 2 +- .../living-line/preview.svg | 1 + .../AgentCompanionDesktopPet.test.tsx | 66 +++++- .../AgentCompanionDesktopPet.tsx | 42 +++- .../LivingLineSettingsSection.test.tsx | 35 +++ .../application/LivingLineSettingsSection.tsx | 91 ++++++++ .../pages/application/PetSettingsSection.tsx | 10 +- .../components/AgentCompanionPet.test.tsx | 21 ++ .../components/AgentCompanionPet.tsx | 13 +- .../components/living-line/LivingLinePet.scss | 26 +++ .../living-line/LivingLinePet.test.tsx | 75 +++++++ .../components/living-line/LivingLinePet.tsx | 59 +++++ .../components/living-line/appearance.ts | 12 + .../components/living-line/engine.test.ts | 99 +++++++++ .../components/living-line/engine.ts | 75 +++++++ .../components/living-line/flow.test.ts | 39 ++++ .../flow_chat/components/living-line/flow.ts | 45 ++++ .../components/living-line/geometry.ts | 120 ++++++++++ .../livingLineSettingsStore.test.ts | 78 +++++++ .../living-line/livingLineSettingsStore.ts | 94 ++++++++ .../components/living-line/preview-entry.tsx | 6 + .../components/living-line/preview.html | 14 ++ .../components/living-line/preview.scss | 26 +++ .../components/living-line/preview.tsx | 206 ++++++++++++++++++ .../components/living-line/settings.test.ts | 28 +++ .../components/living-line/settings.ts | 63 ++++++ .../utils/agentCompanionActivity.test.ts | 15 +- .../flow_chat/utils/agentCompanionActivity.ts | 13 ++ .../registry/defaultAppearanceRegistry.ts | 2 + .../services/AgentCompanionPetService.test.ts | 7 + .../services/AgentCompanionPetService.ts | 2 + .../services/livingLinePetIdentity.test.ts | 11 + .../config/services/livingLinePetIdentity.ts | 16 ++ .../src/locales/en-US/settings/runtime.json | 16 ++ .../src/locales/zh-CN/settings/runtime.json | 16 ++ .../src/locales/zh-TW/settings/runtime.json | 16 ++ 37 files changed, 1446 insertions(+), 21 deletions(-) create mode 100644 src/web-ui/public/agent-companion-pets/living-line/preview.svg create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx create mode 100644 src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx create mode 100644 src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss create mode 100644 src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx create mode 100644 src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx create mode 100644 src/web-ui/src/flow_chat/components/living-line/appearance.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/engine.test.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/engine.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/flow.test.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/flow.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/geometry.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx create mode 100644 src/web-ui/src/flow_chat/components/living-line/preview.html create mode 100644 src/web-ui/src/flow_chat/components/living-line/preview.scss create mode 100644 src/web-ui/src/flow_chat/components/living-line/preview.tsx create mode 100644 src/web-ui/src/flow_chat/components/living-line/settings.test.ts create mode 100644 src/web-ui/src/flow_chat/components/living-line/settings.ts create mode 100644 src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts create mode 100644 src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3044c221ee..ed7ada1f3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,13 @@ packages: - "website" - "tests/e2e" +allowBuilds: + '@parcel/watcher': false + edgedriver: false + esbuild: true + geckodriver: false + sharp: true + patchedDependencies: "@xterm/xterm@6.1.0-beta.141": patches/@xterm__xterm@6.1.0-beta.141.patch mdast-util-gfm-autolink-literal@2.0.1: patches/mdast-util-gfm-autolink-literal@2.0.1.patch diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index a2af3d3d8e..42b1ba4ab8 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -14,7 +14,7 @@ use crate::startup_trace::DesktopStartupTrace; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; const AGENT_COMPANION_WINDOW_MIN_SIZE: f64 = 96.0; -const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; +const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 480.0; const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; const AGENT_COMPANION_WINDOW_EDGE_MARGIN: f64 = 8.0; diff --git a/src/web-ui/public/agent-companion-pets/living-line/preview.svg b/src/web-ui/public/agent-companion-pets/living-line/preview.svg new file mode 100644 index 0000000000..e3399bb3d3 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/living-line/preview.svg @@ -0,0 +1 @@ + diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx index 92b87da952..1e88c6dc7d 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity'; import { AgentCompanionDesktopPet } from './AgentCompanionDesktopPet'; +const companionPet = vi.hoisted(() => ({ selection: null as any })); const emitMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); const listenMock = vi.hoisted(() => vi.fn()); const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); @@ -43,17 +44,17 @@ vi.mock('@tauri-apps/api/window', () => ({ vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { - getSettings: () => ({ agent_companion_pet: null }), + getSettings: () => ({ agent_companion_pet: companionPet.selection }), getSettingsAsync: () => Promise.resolve({ enable_agent_companion: true, - agent_companion_pet: null, + agent_companion_pet: companionPet.selection, }), }, })); vi.mock('@/flow_chat/components/AgentCompanionPet', () => ({ - AgentCompanionPet: ({ lookDirection, action, mood, dragDirection }: { lookDirection?: number | null; action?: string | null; mood?: string; dragDirection?: string }) => ( -
+ AgentCompanionPet: ({ lookDirection, action, mood, dragDirection, livingLineState }: { lookDirection?: number | null; action?: string | null; mood?: string; dragDirection?: string; livingLineState?: string }) => ( +
), })); @@ -147,6 +148,7 @@ describe('AgentCompanionDesktopPet', () => { beforeEach(async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + companionPet.selection = null; emitMock.mockClear(); invokeMock.mockClear(); cursorPositionMock.mockReset(); @@ -200,6 +202,62 @@ describe('AgentCompanionDesktopPet', () => { vi.unstubAllGlobals(); }); + it('reserves the LivingLine rectangle and forwards its persistent state', async () => { + await act(async () => root.unmount()); + companionPet.selection = { + id: 'living-line', displayName: 'LivingLine', source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', + }; + invokeMock.mockClear(); + await act(async () => { + root = createRoot(container); + root.render(); + }); + // Reserve the bubble slot before there is a task, not after it paints. + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 480, height: 198 }); + invokeMock.mockClear(); + pushActivity({ mood: 'rest', tasks: [task({ state: 'error', mood: 'rest' })], livingLineState: 'error', emittedAt: 1, sequence: 1 }); + + expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-width: 288px'); + expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-living-line-state')).toBe('error'); + pushActivity({ mood: 'working', tasks: [task(), task({ sessionId: 'second' })], emittedAt: 2, sequence: 2 }); + pushActivity({ mood: 'rest', tasks: [], emittedAt: 3, sequence: 3 }); + await act(async () => { await Promise.resolve(); }); + // No horizontal shrink/grow/reposition cycle for zero, one or two bubbles. + expect(invokeMock).not.toHaveBeenCalledWith('resize_agent_companion_desktop_pet', expect.anything()); + }); + + it('waits for the LivingLine viewport before revealing bubbles and preserves their nodes', async () => { + vi.stubGlobal('innerWidth', 288); + act(() => window.dispatchEvent(new Event('resize'))); + const settingsListener = listenMock.mock.calls.find(([name]) => name === 'agent-companion://settings-updated')?.[1]; + await act(async () => settingsListener({ payload: { + enable_agent_companion: true, + agent_companion_pet: { + id: 'living-line', displayName: 'LivingLine', source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', + }, + } })); + pushActivity({ mood: 'working', tasks: [task()], emittedAt: 1, sequence: 1 }); + const bubbles = query('.openbitfun-agent-companion-window__bubbles')!; + const bubble = query('.openbitfun-agent-companion-window__bubble'); + expect(bubbles.style.visibility).toBe('hidden'); + expect(bubbles.getAttribute('aria-hidden')).toBe('true'); + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 480, height: 198 }); + + vi.stubGlobal('innerWidth', 480); + act(() => window.dispatchEvent(new Event('resize'))); + expect(bubbles.style.visibility).toBe(''); + expect(bubbles.hasAttribute('aria-hidden')).toBe(false); + expect(query('.openbitfun-agent-companion-window__bubble')).toBe(bubble); + invokeMock.mockClear(); + pushActivity({ mood: 'waiting', tasks: [task({ state: 'attention', latestOutput: 'Reply needed' })], emittedAt: 2, sequence: 2 }); + await act(async () => { await Promise.resolve(); }); + expect(invokeMock).not.toHaveBeenCalledWith('resize_agent_companion_desktop_pet', expect.anything()); + }); + it.each(['before activity', 'after activity'])( 'shows a new task without dragging when the main window gains focus %s', focusOrder => { diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 2a95c7210d..9d9424893f 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -19,17 +19,14 @@ import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExpe import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AgentCompanionPet, type AgentCompanionPetMood } from '@/flow_chat/components/AgentCompanionPet'; import type { AgentCompanionMood } from '@/flow_chat/utils/agentCompanionMood'; -import type { - AgentCompanionActivityPayload, - AgentCompanionTaskState, - AgentCompanionTaskStatus, -} from '@/flow_chat/utils/agentCompanionActivity'; +import { deriveLivingLineState, type AgentCompanionActivityPayload, type AgentCompanionTaskState, type AgentCompanionTaskStatus, type LivingLineActivityState } from '@/flow_chat/utils/agentCompanionActivity'; import type { AgentCompanionPetCommand } from '@/app/services/agentCompanionPetCommands'; import { createLogger } from '@/shared/utils/logger'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; import { getPetLookDirection } from '@/infrastructure/config/services/agentCompanionPetSprite'; import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; import { startAgentCompanionDrag } from '@/infrastructure/config/services/AgentCompanionDragService'; import { prepareAgentCompanionPointerDrag, type CompanionPointerDrag } from '@/infrastructure/config/services/AgentCompanionPointerDragService'; import { createCompanionWindowResizer, isCompanionViewportShort } from './agentCompanionWindowResize'; @@ -40,6 +37,7 @@ const DEFAULT_PET_SIZE = 96; const DEFAULT_PETDEX_DISPLAY_SIZE = { width: 96, height: 104 }; const PETDEX_DESKTOP_SCALE = 0.5; const WINDOW_MAX_WIDTH = 360; +const LIVING_LINE_WINDOW_MAX_WIDTH = 480; const WINDOW_MAX_HEIGHT = 240; const WINDOW_HORIZONTAL_GAP = 8; const MAX_VISIBLE_BUBBLES = 2; @@ -62,6 +60,7 @@ const PET_MAX_DISPLAY_SIZE = { width: WINDOW_MAX_WIDTH - BUBBLE_WIDTH - WINDOW_HORIZONTAL_GAP - WINDOW_EDGE_BUFFER, height: WINDOW_MAX_HEIGHT - WINDOW_VERTICAL_BUFFER * 2, }; + const POINTER_HOVER_POLL_INTERVAL_MS = 120; const PET_LOOK_HOLD_MS = 960; /** Clicks shorter/smaller than this use `show_main_window`; beyond it we start dragging. */ @@ -168,6 +167,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { () => aiExperienceConfigService.getSettings().agent_companion_pet ?? null, ); const [mood, setMood] = useState('rest'); + const [livingLineState, setLivingLineState] = useState('idle'); const [tasks, setTasks] = useState([]); const previousTasksRef = useRef(null); const [reaction, setReaction] = useState<{ action: 'jumping' | 'waving' } | null>(null); @@ -239,10 +239,15 @@ export const AgentCompanionDesktopPet: React.FC = () => { // Task activity and explicit dismissals own visibility. Depending on the // main window's focus made new tasks appear only after touching the pet. const displayTasks = [...visibleTasks].reverse(); + const livingLinePet = isLivingLinePet(pet); + const windowMaxWidth = livingLinePet ? LIVING_LINE_WINDOW_MAX_WIDTH : WINDOW_MAX_WIDTH; + const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const activePetSize = pet && petFrameSize ? petFrameSize - : pet && isGrokLivePet(pet) - ? { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE } + : pet && isLivingLinePet(pet) + ? { width: 288, height: 192 } + : pet && isGrokLivePet(pet) + ? { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE } : pet ? DEFAULT_PETDEX_DISPLAY_SIZE : { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE }; @@ -306,6 +311,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { lastActivityEmittedAtRef.current = emittedAt; lastActivitySequenceRef.current = sequence; setMood(event.payload.mood); + setLivingLineState(event.payload.livingLineState ?? deriveLivingLineState(event.payload.tasks)); // The first snapshot is hydration, not a new request. if (previousTasksRef.current === null) previousTasksRef.current = event.payload.tasks; setTasks(event.payload.tasks); @@ -494,7 +500,12 @@ export const AgentCompanionDesktopPet: React.FC = () => { ) : activePetSize.height) + WINDOW_VERTICAL_BUFFER * 2; const measuredBubbleWidth = bubbleCount > 0 ? BUBBLE_WIDTH : 0; - const measuredDockWidth = bubbleCount > 0 + // Keep the wide renderer's bubble slot reserved even without a task. Native + // resize and reposition are asynchronous, so growing/shrinking by 192px on + // bubble arrival/removal otherwise exposes a clipped, left-shifted frame. + const measuredDockWidth = livingLinePet + ? BUBBLE_WIDTH + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER + : bubbleCount > 0 ? measuredBubbleWidth + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER : Math.max( activePetSize.width, @@ -503,7 +514,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { ); const nextWidth = Math.max( activePetSize.width, - Math.min(WINDOW_MAX_WIDTH, Math.ceil(measuredDockWidth)), + Math.min(windowMaxWidth, Math.ceil(measuredDockWidth)), ); // Rounded up so converting to physical pixels cannot shave a row or column. const nextWindowHeight = Math.ceil(nextHeight); @@ -516,8 +527,11 @@ export const AgentCompanionDesktopPet: React.FC = () => { return; } + const target = windowResizer.target(); + if (target?.width === nextWidth && target.height === nextWindowHeight + && !isCompanionViewportShort({ width: window.innerWidth, height: window.innerHeight }, target)) return; windowResizer.request({ width: nextWidth, height: nextWindowHeight }); - }, [activePetSize.height, activePetSize.width, composingSessionId, visibleTasks, windowResizer]); + }, [activePetSize.height, activePetSize.width, composingSessionId, livingLinePet, visibleTasks, windowMaxWidth, windowResizer]); useEffect(() => { // The host can change the window behind the dock's back (display or DPI @@ -525,6 +539,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { // viewport comes out smaller than the dock needs; the host ignores requests // that already match, so this cannot loop. const reassertWindowSize = () => { + setViewportWidth(window.innerWidth); const target = windowResizer.target(); if (target && isCompanionViewportShort( { width: window.innerWidth, height: window.innerHeight }, @@ -1057,6 +1072,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { '--openbitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, '--openbitfun-agent-companion-vertical-buffer': `${WINDOW_VERTICAL_BUFFER}px`, } as React.CSSProperties; + // Keep bubbles measurable but do not paint them outside the old viewport + // while the initial/switch-to-LivingLine native resize is still pending. + const bubblesFit = !livingLinePet + || viewportWidth + 1 >= BUBBLE_WIDTH + WINDOW_HORIZONTAL_GAP + activePetSize.width; const isSingleTask = visibleTasks.length === 1; const hasAttentionTask = visibleTasks.some(task => task.state === 'attention'); const overlayTask = overlay && overlay.kind !== 'pet-menu' @@ -1120,6 +1139,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { ref={bubblesRef} className={`openbitfun-agent-companion-window__bubbles${isSingleTask ? ' openbitfun-agent-companion-window__bubbles--single' : ''}`} aria-live="polite" + aria-hidden={!bubblesFit || undefined} + style={{ visibility: bubblesFit ? undefined : 'hidden' }} onDoubleClick={event => event.stopPropagation()} data-openbitfun-component="agent-companion-desktop-pet" data-openbitfun-part="bubbles"> {displayTasks.map(task => { @@ -1269,6 +1290,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { mood={displayMood} needsAttention={tasks.some(task => task.state === 'attention')} toolWaiting={tasks.some(task => task.state === 'waiting')} + livingLineState={livingLineState} dragDirection={dragDirection} action={!isDraggingPet ? mood === 'rest' && visibleTasks.some(task => task.state === 'error') ? 'failed' : reaction?.action ?? null diff --git a/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx new file mode 100644 index 0000000000..fd89331169 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LivingLineSettingsSection from './LivingLineSettingsSection'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: vi.fn(), success: vi.fn() }) })); + +describe('LivingLine settings section', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + localStorage.clear(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + localStorage.clear(); + }); + + it('renders 4 color and 8 numeric controls plus reset', async () => { + await act(async () => root.render()); + expect(container.querySelectorAll('input[type="color"]').length).toBe(4); + expect(container.querySelectorAll('input[type="range"]').length).toBe(8); + expect(container.querySelectorAll('output').length).toBe(8); + expect(container.querySelector('button')?.textContent).toContain('reset'); + }); +}); \ No newline at end of file diff --git a/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx new file mode 100644 index 0000000000..11bfd0cb81 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/application/LivingLineSettingsSection.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common'; +import { RANGES } from '@/flow_chat/components/living-line/settings'; +import { + DEFAULT_SETTINGS, + type LivingLineSettings, +} from '@/flow_chat/components/living-line/settings'; +import { + readLivingLineSettings, + saveLivingLineSettings, + subscribeLivingLineSettings, +} from '@/flow_chat/components/living-line/livingLineSettingsStore'; + +const log = createLogger('LivingLineSettingsSection'); + +const STEP_BY_FIELD: Partial> = { + lineWidth: 0.1, + flowLength: 1, + flowWidth: 0.1, + flowBlur: 0.5, + flowIntensity: 0.05, + lineGlowBlur: 0.5, + idlePeriodSeconds: 0.5, + workingPeriodSeconds: 0.1, +}; + +const COLOR_FIELDS: Array = ['lineColor', 'questionColor', 'errorColor', 'flowColor']; +const NUMBER_FIELDS: Array = [ + 'lineWidth', 'flowLength', 'flowWidth', 'flowBlur', 'flowIntensity', + 'lineGlowBlur', 'idlePeriodSeconds', 'workingPeriodSeconds', +]; + +export default function LivingLineSettingsSection() { + const { t } = useI18n('settings/runtime'); + const notification = useNotification(); + const [settings, setSettings] = useState(readLivingLineSettings); + + useEffect(() => subscribeLivingLineSettings(() => setSettings(readLivingLineSettings())), []); + + const update = (patch: Partial) => { + try { + setSettings(saveLivingLineSettings(patch)); + } catch (error) { + log.error('Failed to save LivingLine settings', error); + notification.error(t('messages.saveFailed')); + } + }; + + return ( + update(DEFAULT_SETTINGS)}> + {t('features.pet.livingLine.reset')} + + } + > + {COLOR_FIELDS.map(field => ( + + update({ [field]: event.target.value } as Partial)} + /> + + ))} + {NUMBER_FIELDS.map(field => { + const [min, max] = RANGES[field as keyof typeof RANGES]; + return ( + + update({ [field]: Number(event.target.value) } as Partial)} + /> + {settings[field]} + + ); + })} + + ); +} \ No newline at end of file diff --git a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx index 22f6a0a228..cb9ec2a11b 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/PetSettingsSection.tsx @@ -18,6 +18,8 @@ import { } from '@/infrastructure/config/services/AgentCompanionPetService'; import { getPetSpriteLayout } from '@/infrastructure/config/services/agentCompanionPetSprite'; import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; +import LivingLineSettingsSection from './LivingLineSettingsSection'; import { DEFAULT_GROK_LIVE_SETTINGS, readGrokLiveSettings, saveGrokLiveSettings, subscribeGrokLiveSettings, type GrokLiveSettings } from '@/flow_chat/components/grok-live/grokLiveSettings'; import { aiExperienceConfigService, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; @@ -41,11 +43,12 @@ const log = createLogger('PetSettingsSection'); const IS_TAURI_DESKTOP = typeof window !== 'undefined' && '__TAURI__' in window; function getPetPreviewStyle(pet: AgentCompanionPetPackage): React.CSSProperties { + const vectorPet = isGrokLivePet(pet) || isLivingLinePet(pet); return { - imageRendering: isGrokLivePet(pet) || (pet.source === 'preset' && pet.id === 'bitblob') ? 'auto' : undefined, + imageRendering: vectorPet || (pet.source === 'preset' && pet.id === 'bitblob') ? 'auto' : undefined, '--openbitfun-pet-preview-src': `url("${pet.previewSrc}")`, - backgroundSize: isGrokLivePet(pet) ? 'contain' : `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, - ...(isGrokLivePet(pet) ? { backgroundPosition: 'center', animation: 'none' } : {}), + backgroundSize: vectorPet ? 'contain' : `800% ${getPetSpriteLayout(pet.spriteVersionNumber).rows * 100}%`, + ...(vectorPet ? { backgroundPosition: 'center', animation: 'none' } : {}), } as React.CSSProperties; } @@ -469,6 +472,7 @@ const PetSettingsSection: React.FC<{ isActive?: boolean }> = ({ isActive = true )} )} + {isLivingLinePet(selectedCompanionPetReference) && } )} diff --git a/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx b/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx index 303f834a4d..5fca3cf6f3 100644 --- a/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx +++ b/src/web-ui/src/flow_chat/components/AgentCompanionPet.test.tsx @@ -14,6 +14,10 @@ const pet: AgentCompanionPetSelection = { id: 'sample', displayName: 'Sample', source: 'preset', packagePath: '/sample', spritesheetPath: '/sample/spritesheet.webp', spritesheetMimeType: 'image/webp', spriteVersionNumber: 2, }; +const livingLine: AgentCompanionPetSelection = { + id: 'living-line', displayName: 'LivingLine', source: 'preset', packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', spritesheetMimeType: 'image/svg+xml', +}; let root: Root; let container: HTMLDivElement; @@ -50,6 +54,23 @@ function sprite() { return container.querySelector('[data-openbitfun-part="petdex"]')!; } +describe('AgentCompanionPet renderer routing', () => { + it('renders the independent LivingLine renderer and reports its rectangular frame', async () => { + const onSize = vi.fn(); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + await act(async () => root.render( + , + )); + + expect(container.querySelector('.living-line-pet')?.getAttribute('data-openbitfun-state')).toBe('question'); + expect(container.querySelector('svg.living-line-art')?.getAttribute('viewBox')).toBe('0 0 256 144'); + expect(onSize).toHaveBeenCalledWith({ width: 288, height: 192 }); + expect(resolvePet).not.toHaveBeenCalled(); + }); +}); + describe('pet sprite renderer', () => { it('restores BitBlob atlas-only animation after look tracking expires', async () => { const bitblob = { ...pet, id: 'bitblob' }; diff --git a/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx b/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx index 84b14603b4..dbfb20aca0 100644 --- a/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx +++ b/src/web-ui/src/flow_chat/components/AgentCompanionPet.tsx @@ -27,6 +27,9 @@ import type { AgentCompanionMood } from '../utils/agentCompanionMood'; import type { AgentCompanionPetSelection } from '@/infrastructure/config/services/AIExperienceConfigService'; import { resolveAgentCompanionPet } from '@/infrastructure/config/services/AgentCompanionPetService'; import { isGrokLivePet } from '@/infrastructure/config/services/grokLivePetIdentity'; +import { isLivingLinePet } from '@/infrastructure/config/services/livingLinePetIdentity'; +import { LivingLinePet } from './living-line/LivingLinePet'; +import type { LivingState } from './living-line/geometry'; import { GrokLivePet } from './GrokLivePet'; import { getPetAnimationFrameCount, getPetLookFrame, getPetSpriteFrameSize } from '@/infrastructure/config/services/agentCompanionPetSprite'; import { createLogger } from '@/shared/utils/logger'; @@ -46,6 +49,7 @@ export interface AgentCompanionPetProps { action?: 'jumping' | 'waving' | 'failed' | null; needsAttention?: boolean; toolWaiting?: boolean; + livingLineState?: LivingState; dragDirection?: 'left' | 'right'; onPetFrameSizeChange?: (size: { width: number; height: number } | null) => void; } @@ -597,5 +601,10 @@ const SpriteAgentCompanionPet: React.FC = ({ ); }; -export const AgentCompanionPet: React.FC = (props) => - isGrokLivePet(props.pet) ? : ; +export const AgentCompanionPet: React.FC = (props) => { + if (isLivingLinePet(props.pet)) { + const state = props.livingLineState ?? (props.mood === 'waiting' ? 'question' : props.mood === 'working' || props.mood === 'analyzing' ? 'working' : 'idle'); + return ; + } + return isGrokLivePet(props.pet) ? : ; +}; diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss new file mode 100644 index 0000000000..40fb0db475 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss @@ -0,0 +1,26 @@ +.openbitfun-agent-companion-pet.living-line-pet { + width: 288px; + height: 192px; + flex: 0 0 288px; + overflow: visible; + pointer-events: none; +} + +.living-line-pet__art { + display: block; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + + path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + } + + .state-ring { + fill: none; + } +} diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx new file mode 100644 index 0000000000..626e645440 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LivingLinePet, LIVING_LINE_FRAME_SIZE } from './LivingLinePet'; +import { LivingLineEngine } from './engine'; +import { DEFAULT_SETTINGS, LIVING_LINE_SETTINGS_KEY } from './settings'; +import { saveLivingLineSettings } from './livingLineSettingsStore'; + +describe('LivingLine product settings integration', () => { + let container: HTMLDivElement; + let root: Root; + let nextFrame: FrameRequestCallback; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { nextFrame = callback; return 1; }); + const cancelFrame = vi.fn(); + const onFrame = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('BroadcastChannel', undefined); + vi.stubGlobal('requestAnimationFrame', requestFrame); + vi.stubGlobal('cancelAnimationFrame', cancelFrame); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))); + localStorage.clear(); + requestFrame.mockClear(); cancelFrame.mockClear(); onFrame.mockClear(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('hot-updates colors and widths without remounting the renderer or resetting its state', () => { + const setState = vi.spyOn(LivingLineEngine.prototype, 'setState'); + act(() => root.render()); + const svg = container.querySelector('svg'); + const main = container.querySelector('.main-path')!; + expect(onFrame).toHaveBeenCalledExactlyOnceWith(LIVING_LINE_FRAME_SIZE); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(setState).toHaveBeenCalledTimes(1); + + act(() => saveLivingLineSettings({ lineColor: '#123456', lineWidth: 4.5 })); + expect(container.querySelector('svg')).toBe(svg); + expect(container.querySelector('.main-path')).toBe(main); + expect(main.getAttribute('stroke')).toBe('#123456'); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(setState).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledTimes(1); + act(() => { nextFrame(100); nextFrame(116); }); + expect((main as SVGElement).style.strokeWidth).toBe('4.5px'); + }); + + it('restores defaults after cross-window removal or clear and cleans up on unmount', () => { + saveLivingLineSettings({ lineColor: '#123456' }); + act(() => root.render()); + const svg = container.querySelector('svg'); + localStorage.removeItem(LIVING_LINE_SETTINGS_KEY); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: LIVING_LINE_SETTINGS_KEY, newValue: null }))); + expect(container.querySelector('.main-path')?.getAttribute('stroke')).toBe(DEFAULT_SETTINGS.lineColor); + act(() => saveLivingLineSettings({ lineColor: '#654321' })); + localStorage.clear(); + act(() => window.dispatchEvent(new StorageEvent('storage', { key: null }))); + expect(container.querySelector('svg')).toBe(svg); + expect(container.querySelector('.main-path')?.getAttribute('stroke')).toBe(DEFAULT_SETTINGS.lineColor); + act(() => root.render(null)); + expect(cancelFrame).toHaveBeenCalledWith(1); + expect(onFrame).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx new file mode 100644 index 0000000000..13540b2e66 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import type { AgentCompanionPetMood } from '../AgentCompanionPet'; +import { LivingLineRenderer } from './preview'; +import { readLivingLineSettings, subscribeLivingLineSettings } from './livingLineSettingsStore'; +import type { LivingState } from './geometry'; +import './LivingLinePet.scss'; + +export const LIVING_LINE_FRAME_SIZE = { width: 288, height: 192 } as const; + +export function LivingLinePet({ + state, + mood, + className = '', + onFrameSizeChange, +}: { + state: LivingState; + mood: AgentCompanionPetMood; + className?: string; + onFrameSizeChange?: (size: { width: number; height: number } | null) => void; +}) { + const [settings, setSettings] = useState(readLivingLineSettings); + const [reducedMotion, setReducedMotion] = useState(() => + typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); + + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setReducedMotion(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + useEffect(() => subscribeLivingLineSettings(() => setSettings(readLivingLineSettings())), []); + + useEffect(() => { + onFrameSizeChange?.(LIVING_LINE_FRAME_SIZE); + return () => onFrameSizeChange?.(null); + }, [onFrameSizeChange]); + + return ( + + ); +} diff --git a/src/web-ui/src/flow_chat/components/living-line/appearance.ts b/src/web-ui/src/flow_chat/components/living-line/appearance.ts new file mode 100644 index 0000000000..0f1ec70fb0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/appearance.ts @@ -0,0 +1,12 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const livingLinePreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'living-line-preview', + parts: [ + { id: 'root' }, + { id: 'stage' }, + { id: 'controls' }, + { id: 'options' }, + { id: 'settings' }, + ], +}; \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.test.ts b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts new file mode 100644 index 0000000000..fc99c76a27 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { GEOMETRY, IDLE_WAVE_MODES, idleWaveLines, MARK, MARK_RADIUS, SOURCE_PATHS, toPolyline, workingCoreStrokeWidth, workingHoverLines } from './geometry'; +import { LivingLineEngine } from './engine'; + +describe('LivingLine geometry and engine', () => { + it('uses the approved v0.4 SVG paths and fixed anchors', () => { + expect(SOURCE_PATHS.working).toContain('M38,84L76,84L83,80L90,84'); + expect(SOURCE_PATHS.question).toMatch(/^M38,84C/); + expect(SOURCE_PATHS.question).toContain('149,53.25'); + expect(SOURCE_PATHS.error).toContain('128,36'); + for (const state of ['working', 'question', 'error'] as const) { + expect(GEOMETRY[state][0]).toEqual({ x: 38, y: 84 }); + expect(GEOMETRY[state].at(-1)).toEqual({ x: 218, y: 84 }); + } + }); + it('flattens the approved paths to shared M/L vertices while retaining landmarks', () => { + const lengths = new Set(Object.values(GEOMETRY).map(points => points.length)); + expect(lengths.size).toBe(1); + expect(GEOMETRY.working.length).toBeGreaterThan(128); + const d = toPolyline(GEOMETRY.working); + expect(d.match(/[A-Z]/g)).toEqual(['M', ...Array(GEOMETRY.working.length - 1).fill('L')]); + expect(GEOMETRY.working).toContainEqual({ x: 116, y: 58 }); + expect(GEOMETRY.working).toContainEqual({ x: 123, y: 95 }); + }); + it('uses matching hollow-ring sizes and a fixed center from the approved assets', () => { + expect(MARK).toMatchObject({ cx: 128, cy: 106, questionRadius: 4.2, errorRadius: 4.8, strokeWidth: 3.2 }); + expect(MARK_RADIUS).toMatchObject({ idle: 0, working: 0, question: 4.2, error: 4.8 }); + }); + it('generates varied idle waves with fixed anchors and optional second line', () => { + const single = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[0]); + const layered = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[1]); + const split = idleWaveLines(GEOMETRY.idle, 0.8, IDLE_WAVE_MODES[1], true); + expect(single).toHaveLength(1); + expect(layered).toHaveLength(2); + expect(split).toHaveLength(3); + expect(new Set(split.map(toPolyline)).size).toBe(3); + for (const line of [...single, ...layered, ...split]) { + expect(line[0]).toEqual(GEOMETRY.idle[0]); + expect(line.at(-1)).toEqual(GEOMETRY.idle.at(-1)); + } + expect(new Set(IDLE_WAVE_MODES.map(mode => `${mode.cycles}:${mode.amplitude}`)).size).toBeGreaterThan(1); + const sparse = idleWaveLines([{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }], 0.8, IDLE_WAVE_MODES[1])[0]; + const dense = idleWaveLines([{ x: 0, y: 0 }, { x: 12, y: 0 }, { x: 50, y: 0 }, { x: 88, y: 0 }, { x: 100, y: 0 }], 0.8, IDLE_WAVE_MODES[1])[0]; + expect(sparse[1].y).toBeCloseTo(dense[2].y, 8); + }); + it('thickens the working core continuously as split lines merge', () => { + expect(workingCoreStrokeWidth(1)).toBeCloseTo(1.792, 3); + expect(workingCoreStrokeWidth(0.5)).toBeCloseTo(2.496, 3); + expect(workingCoreStrokeWidth(0)).toBe(3.2); + expect(workingCoreStrokeWidth(-1)).toBe(3.2); + expect(workingCoreStrokeWidth(2)).toBeCloseTo(1.792, 3); + }); + it('creates three distinct M/L hover ECGs with matching endpoints', () => { + const paths = workingHoverLines(GEOMETRY.working, 0.8); + expect(paths).toHaveLength(3); + expect(new Set(paths.map(toPolyline)).size).toBe(3); + for (const points of paths) { + expect(points[0]).toEqual(GEOMETRY.working[0]); + expect(points.at(-1)).toEqual(GEOMETRY.working.at(-1)); + expect(toPolyline(points)).not.toMatch(/[CQASTZ]/i); + } + }); + it('keeps both endpoints fixed during updates', () => { + const engine = new LivingLineEngine(); + const before = engine.getPoints(); + engine.setState('working'); + const after = engine.step(1 / 60); + expect(after[0]).toEqual(before[0]); + expect(after.at(-1)).toEqual(before.at(-1)); + }); + it('eases the hollow dot from invisible center to its approved radius', () => { + const engine = new LivingLineEngine(); + expect(engine.getMarkRadius()).toBe(0); + engine.setState('question'); + engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeGreaterThan(0); + expect(engine.getMarkRadius()).toBeLessThan(MARK.questionRadius); + for (let i = 0; i < 600; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeCloseTo(MARK.questionRadius, 2); + }); + it('springs the fixed-center ring back to an invisible point on exit', () => { + const engine = new LivingLineEngine(); + engine.setState('error'); + for (let i = 0; i < 240; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBeCloseTo(MARK.errorRadius, 2); + engine.setState('idle'); + expect(engine.getMarkRadius()).toBeGreaterThan(0); + for (let i = 0; i < 240; i++) engine.step(1 / 60); + expect(engine.getMarkRadius()).toBe(0); + }); + it('jumps directly to targets when reduced motion is enabled', () => { + const engine = new LivingLineEngine(); + engine.setReducedMotion(true); + engine.setState('error'); + expect(engine.settled).toBe(true); + expect(engine.getMarkRadius()).toBe(MARK.errorRadius); + expect(engine.step(1 / 60)).toEqual(engine.getPoints()); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.ts b/src/web-ui/src/flow_chat/components/living-line/engine.ts new file mode 100644 index 0000000000..a6cc5fc603 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/engine.ts @@ -0,0 +1,75 @@ +import { GEOMETRY, MARK_RADIUS, type LivingState, type Point } from './geometry'; + +type MutablePoint = { x: number; y: number }; + +export class LivingLineEngine { + private current: MutablePoint[]; + private target: MutablePoint[]; + private velocity: MutablePoint[]; + private markRadius = 0; + private markTarget = 0; + private markVelocity = 0; + private state: LivingState = 'idle'; + private reducedMotion = false; + private readonly start: Point; + private readonly end: Point; + + constructor(private readonly stiffness = 150, private readonly damping = 24) { + this.current = GEOMETRY.idle.map(p => ({ ...p })); + this.target = this.current.map(p => ({ ...p })); + this.velocity = this.current.map(() => ({ x: 0, y: 0 })); + this.start = { ...this.current[0] }; + this.end = { ...this.current[this.current.length - 1] }; + } + + getState(): LivingState { return this.state; } + getPoints(): readonly Point[] { return this.current.map(p => ({ ...p })); } + getMarkRadius(): number { return this.markRadius; } + get settled(): boolean { + return this.markRadius === this.markTarget && this.current.every((p, i) => Math.abs(p.x - this.target[i].x) < 0.05 && Math.abs(p.y - this.target[i].y) < 0.05); + } + + setReducedMotion(enabled: boolean): void { + this.reducedMotion = enabled; + if (enabled) this.snapToTarget(); + } + + setState(state: LivingState): void { + this.state = state; + this.target = GEOMETRY[state].map(p => ({ ...p })); + this.target[0] = { ...this.start }; + this.target[this.target.length - 1] = { ...this.end }; + this.markTarget = MARK_RADIUS[state]; + if (this.reducedMotion) this.snapToTarget(); + } + + step(deltaSeconds: number): readonly Point[] { + if (this.reducedMotion || !this.current.length) return this.getPoints(); + const dt = Math.min(Math.max(deltaSeconds, 0), 1 / 30); + for (let i = 1; i < this.current.length - 1; i++) { + for (const axis of ['x', 'y'] as const) { + const acceleration = this.stiffness * (this.target[i][axis] - this.current[i][axis]) - this.damping * this.velocity[i][axis]; + this.velocity[i][axis] += acceleration * dt; + this.current[i][axis] += this.velocity[i][axis] * dt; + } + } + const markAcceleration = this.stiffness * (this.markTarget - this.markRadius) - this.damping * this.markVelocity; + this.markVelocity += markAcceleration * dt; + const previousRadius = this.markRadius; + this.markRadius = Math.max(0, this.markRadius + this.markVelocity * dt); + if ((previousRadius - this.markTarget) * (this.markRadius - this.markTarget) <= 0 || (Math.abs(this.markRadius - this.markTarget) < 0.01 && Math.abs(this.markVelocity) < 0.02)) { + this.markRadius = this.markTarget; + this.markVelocity = 0; + } + this.current[0] = { ...this.start }; + this.current[this.current.length - 1] = { ...this.end }; + return this.getPoints(); + } + + private snapToTarget(): void { + this.current = this.target.map(p => ({ ...p })); + this.velocity = this.current.map(() => ({ x: 0, y: 0 })); + this.markRadius = this.markTarget; + this.markVelocity = 0; + } +} diff --git a/src/web-ui/src/flow_chat/components/living-line/flow.test.ts b/src/web-ui/src/flow_chat/components/living-line/flow.test.ts new file mode 100644 index 0000000000..3cc1d71e44 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/flow.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { approachFlowOpacity, polylineLength, sliceFlowHighlight } from './flow'; + +const near = (a: number, b: number) => Math.abs(a - b) < 0.001; + +describe('arc-length flow highlight', () => { + it('measures the rendered path rather than its vertex count', () => { + expect(polylineLength([{ x: 0, y: 0 }, { x: 3, y: 4 }, { x: 13, y: 4 }])).toBe(15); + }); + it('moves a fixed-length segment at equal arc distance across unequal edges', () => { + const path = [{ x: 0, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 9 }]; + const first = sliceFlowHighlight(path, 0.2, 2); + const second = sliceFlowHighlight(path, 0.4, 2); + expect(polylineLength(first)).toBeCloseTo(2, 3); + expect(polylineLength(second)).toBeCloseTo(2, 3); + expect(first.at(-1)!.x).toBeGreaterThan(0); + expect(second.at(-1)!.y).toBeGreaterThan(first.at(-1)!.y); + }); + it('leaves the path empty during the full off-path loop gap', () => { + const path = [{ x: 0, y: 0 }, { x: 20, y: 0 }]; + expect(sliceFlowHighlight(path, 0, 5)).toEqual([]); + expect(sliceFlowHighlight(path, 0.9, 5)).toEqual([]); + expect(sliceFlowHighlight(path, 0.5, 5).length).toBeGreaterThan(0); + }); + it('fades streamlines smoothly when the active line count changes', () => { + const entering = approachFlowOpacity(0, true, 1 / 60); + const leaving = approachFlowOpacity(1, false, 1 / 60); + expect(entering).toBeGreaterThan(0); + expect(entering).toBeLessThan(1); + expect(leaving).toBeGreaterThan(0); + expect(leaving).toBeLessThan(1); + expect(approachFlowOpacity(entering, true, 1 / 60)).toBeGreaterThan(entering); + }); + it('returns nothing for degenerate paths and hotspot lengths', () => { + expect(sliceFlowHighlight([{ x: 1, y: 1 }], 0.5, 5)).toEqual([]); + expect(sliceFlowHighlight([{ x: 0, y: 0 }, { x: 0, y: 0 }], 0.5, 5)).toEqual([]); + expect(sliceFlowHighlight([{ x: 0, y: 0 }, { x: 1, y: 0 }], 0.5, 0)).toEqual([]); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/flow.ts b/src/web-ui/src/flow_chat/components/living-line/flow.ts new file mode 100644 index 0000000000..f67982c2d6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/flow.ts @@ -0,0 +1,45 @@ +import type { Point } from './geometry'; + +type ArcTable = { points: readonly Point[]; lengths: number[]; total: number }; + +function arcTable(points: readonly Point[]): ArcTable { + const lengths = [0]; + for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)); + return { points, lengths, total: lengths[lengths.length - 1] }; +} + +function atDistance(table: ArcTable, distance: number): Point { + let i = 1; + while (i < table.lengths.length - 1 && table.lengths[i] < distance) i++; + const span = table.lengths[i] - table.lengths[i - 1]; + const t = span ? (distance - table.lengths[i - 1]) / span : 0; + const a = table.points[i - 1]; + const b = table.points[i]; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export function polylineLength(points: readonly Point[]): number { + return arcTable(points).total; +} + +export function approachFlowOpacity(current: number, active: boolean, deltaSeconds: number, fadeSeconds = 0.32): number { + const amount = 1 - Math.exp(-Math.max(0, deltaSeconds) / Math.max(0.001, fadeSeconds)); + return current + ((active ? 1 : 0) - current) * amount; +} + +/** progress moves the hotspot head through the path, then leaves a full hotspot-length gap before re-entering. */ +export function sliceFlowHighlight(points: readonly Point[], progress: number, hotspotLength: number): readonly Point[] { + const table = arcTable(points); + if (table.total <= 0 || points.length < 2 || hotspotLength <= 0) return []; + const head = ((progress % 1) + 1) % 1 * (table.total + hotspotLength * 2); + if (head <= 0 || head >= table.total + hotspotLength) return []; + const start = Math.max(0, head - hotspotLength); + const end = Math.min(table.total, head); + if (end <= start) return []; + const slice = [atDistance(table, start)]; + for (let i = 1; i < table.lengths.length - 1; i++) { + if (table.lengths[i] > start && table.lengths[i] < end) slice.push(table.points[i]); + } + slice.push(atDistance(table, end)); + return slice; +} diff --git a/src/web-ui/src/flow_chat/components/living-line/geometry.ts b/src/web-ui/src/flow_chat/components/living-line/geometry.ts new file mode 100644 index 0000000000..7af8d2d6a0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/geometry.ts @@ -0,0 +1,120 @@ +export type Point = Readonly<{ x: number; y: number }>; +export type LivingState = 'idle' | 'working' | 'question' | 'error'; +type FlatPoint = { x: number; y: number }; + +// Path data is transcribed from the locally approved v0.4 SVG masters; cubic curves are flattened only for animation. +export const SOURCE_PATHS: Record = { + idle: 'M38,84L218,84', + working: 'M38,84L76,84L83,80L90,84L103,84L109,89L116,58L123,95L130,84L148,84L159,79L170,84L218,84', + question: 'M38,84C66.60009765625,84,95.2001953125,84,123.7998046875,84C123.7998046875,81,123.7998046875,78,123.7998046875,75C123.7998046875,69,127.2998046875,66,132.89990234375,63C138.5,60,141.2998046875,57,141.2998046875,53.25C141.2998046875,47.25,135.7001953125,42.75,128.7001953125,42.75C121,42.75,115.39990234375,47.25,115.39990234375,54C115.39990234375,55.5,115.39990234375,57,115.39990234375,57C113.2998046875,57,110.5,57,108.39990234375,57C108.39990234375,56.25,108.39990234375,54.75,108.39990234375,54C108.39990234375,42.75,116.7998046875,35.25,128.7001953125,35.25C140.60009765625,35.25,149,42.75,149,53.25C149,61.5,144.7998046875,66,137.7998046875,69.75C133.60009765625,72,132.2001953125,74.25,132.2001953125,77.25C132.2001953125,79.5,132.2001953125,81.75,132.2001953125,84C160.7998046875,84,189.39990234375,84,218,84', + error: 'M38,84C66.39990234375,84,94.7998046875,84,123.2001953125,84C123.2001953125,72,122.2998046875,61,121.7001953125,51C121.10009765625,44,122.2998046875,36,128,36C133.7001953125,36,134.89990234375,44,134.2998046875,51C133.7001953125,61,132.7998046875,72,132.7998046875,84C161.2001953125,84,189.60009765625,84,218,84', +}; + +function flattenPath(path: string): FlatPoint[] { + const tokens = path.match(/[MLC]|-?(?:\d+\.?\d*|\.\d+)/g) ?? []; + const points: FlatPoint[] = []; + let i = 0; + while (i < tokens.length) { + const command = tokens[i++]; + if (command === 'M' || command === 'L') { + points.push({ x: Number(tokens[i++]), y: Number(tokens[i++]) }); + continue; + } + if (command !== 'C') throw new Error(`Unsupported LivingLine path command: ${command}`); + const p0 = points[points.length - 1]; + const p1 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + const p2 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + const p3 = { x: Number(tokens[i++]), y: Number(tokens[i++]) }; + if (!p0) throw new Error('LivingLine cubic segment has no start point'); + for (let step = 1; step <= 12; step++) { + const t = step / 12; + const u = 1 - t; + points.push({ + x: u ** 3 * p0.x + 3 * u ** 2 * t * p1.x + 3 * u * t ** 2 * p2.x + t ** 3 * p3.x, + y: u ** 3 * p0.y + 3 * u ** 2 * t * p1.y + 3 * u * t ** 2 * p2.y + t ** 3 * p3.y, + }); + } + } + return points; +} + +function arcTable(points: readonly Point[]) { + const lengths = [0]; + for (let i = 1; i < points.length; i++) { + lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)); + } + return { points, lengths, total: lengths[lengths.length - 1] }; +} + +const tables = Object.fromEntries(Object.entries(SOURCE_PATHS).map(([state, path]) => [state, arcTable(flattenPath(path))])) as Record>; +const landmarks = [...new Set([ + ...Array.from({ length: 129 }, (_, i) => i / 128), + ...Object.values(tables).flatMap(({ lengths, total }) => lengths.map(length => length / total)), +])].sort((a, b) => a - b); + +function atLength(table: ReturnType, fraction: number): Point { + const distance = fraction * table.total; + let i = 1; + while (i < table.lengths.length - 1 && table.lengths[i] < distance) i++; + const span = table.lengths[i] - table.lengths[i - 1]; + const t = span ? (distance - table.lengths[i - 1]) / span : 0; + const a = table.points[i - 1]; + const b = table.points[i]; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export const GEOMETRY: Record = { + idle: landmarks.map(fraction => atLength(tables.idle, fraction)), + working: landmarks.map(fraction => atLength(tables.working, fraction)), + question: landmarks.map(fraction => atLength(tables.question, fraction)), + error: landmarks.map(fraction => atLength(tables.error, fraction)), +}; +export const WIDTH = 256; +export const HEIGHT = 144; +export const MARK = { cx: 128, cy: 106, questionRadius: 4.2, errorRadius: 4.8, strokeWidth: 3.2 } as const; +export const MARK_RADIUS: Record = { idle: 0, working: 0, question: MARK.questionRadius, error: MARK.errorRadius }; + +export function toPolyline(points: readonly Point[]): string { + return points.map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(3)} ${p.y.toFixed(3)}`).join(' '); +} + +export type IdleWaveMode = Readonly<{ cycles: number; amplitude: number; secondCycles: number; secondAmplitude: number; secondOpacity: number }>; +export const IDLE_WAVE_MODES: readonly IdleWaveMode[] = [ + { cycles: 1.6, amplitude: 10, secondCycles: 0, secondAmplitude: 0, secondOpacity: 0 }, + { cycles: 2.4, amplitude: 12, secondCycles: 1.65, secondAmplitude: 4.8, secondOpacity: 0.24 }, + { cycles: 3, amplitude: 9.5, secondCycles: 2.1, secondAmplitude: 4, secondOpacity: 0.18 }, +]; + +export function idleWaveLines(points: readonly Point[], phase: number, mode: IdleWaveMode, splitProgress = 0): Point[][] { + const startX = points[0]?.x ?? 0; + const spanX = (points.at(-1)?.x ?? startX) - startX || 1; + const makeWave = (cycles: number, amplitude: number, offset: number, speed: number) => points.map(point => { + const u = (point.x - startX) / spanX; + const envelope = Math.sin(Math.PI * u) ** 1.2; + return { x: point.x, y: point.y + envelope * amplitude * Math.sin(u * cycles * Math.PI * 2 + phase * speed + offset) }; + }); + const primary = makeWave(mode.cycles, mode.amplitude, 0, 1); + if (splitProgress > 0) { + return [primary, ...[ + makeWave(mode.cycles * 1.12, mode.amplitude * 0.82, 2.15, -0.82), + makeWave(mode.cycles * 1.43, mode.amplitude * 0.94, 4.1, 0.68), + ].map(target => target.map((point, index) => ({ + x: point.x, + y: primary[index].y + (point.y - primary[index].y) * splitProgress, + })))]; + } + return mode.secondOpacity <= 0.001 ? [primary] : [primary, makeWave(mode.secondCycles, mode.secondAmplitude, 1.3, -0.82)]; +} + +export function workingCoreStrokeWidth(splitProgress: number, lineWidth = 3.2): number { + const split = Math.min(1, Math.max(0, splitProgress)); + return lineWidth * 0.56 + (lineWidth - lineWidth * 0.56) * (1 - split); +} + +export function workingHoverLines(points: readonly Point[], phase: number, split = 1): Point[][] { + return [-1, 0, 1].map((lane, laneIndex) => points.map((point, index) => { + if (index === 0 || index === points.length - 1) return { ...point }; + const wave = Math.sin(index * 1.37 + phase + laneIndex * 2.05); + return { x: point.x, y: point.y + split * (lane * (6.5 + Math.abs(wave) * 2) + wave * 2.4) }; + })); +} diff --git a/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts new file mode 100644 index 0000000000..c5d5c1e4c5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LIVING_LINE_SETTINGS_KEY, + readLivingLineSettings, + saveLivingLineSettings, + subscribeLivingLineSettings, +} from './livingLineSettingsStore'; +import { DEFAULT_SETTINGS } from './settings'; + +describe('LivingLine settings store', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('reads defaults when storage is empty or corrupt', () => { + expect(readLivingLineSettings()).toEqual(DEFAULT_SETTINGS); + localStorage.setItem(LIVING_LINE_SETTINGS_KEY, '{bad'); + expect(readLivingLineSettings()).toEqual(DEFAULT_SETTINGS); + }); + + it('saves and reads a validated patch', () => { + const next = saveLivingLineSettings({ lineWidth: 5 }); + expect(next.lineWidth).toBe(5); + expect(readLivingLineSettings().lineWidth).toBe(5); + }); + + it('throws instead of falsely succeeding on invalid or unavailable storage', () => { + expect(() => saveLivingLineSettings({ lineWidth: 999 })).toThrow(); + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + expect(() => saveLivingLineSettings({ flowBlur: 2 })).toThrow(); + setItem.mockRestore(); + }); + + it('notifies listeners for same-window custom events', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + saveLivingLineSettings({ flowWidth: 3 }); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies listeners on storage removal and clear', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + localStorage.removeItem(LIVING_LINE_SETTINGS_KEY); + window.dispatchEvent(new StorageEvent('storage', { key: LIVING_LINE_SETTINGS_KEY, newValue: null })); + expect(listener).toHaveBeenCalled(); + listener.mockClear(); + window.dispatchEvent(new StorageEvent('storage', { key: null, newValue: null })); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies listeners on focus', () => { + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + window.dispatchEvent(new Event('focus')); + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('supports BroadcastChannel cross-window notifications when available', async () => { + if (typeof BroadcastChannel === 'undefined') return; + const listener = vi.fn(); + const unsubscribe = subscribeLivingLineSettings(listener); + const channel = new BroadcastChannel('openbitfun-living-line-settings'); + channel.postMessage('changed'); + await vi.waitFor(() => expect(listener).toHaveBeenCalled()); + channel.close(); + unsubscribe(); + }); +}); \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts new file mode 100644 index 0000000000..29dc9920ee --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/livingLineSettingsStore.ts @@ -0,0 +1,94 @@ +import { + DEFAULT_SETTINGS, + LIVING_LINE_SETTINGS_KEY, + type LivingLineSettings, + validateSettings, +} from './settings'; + +export { + DEFAULT_SETTINGS, + LIVING_LINE_SETTINGS_KEY, + loadSettings, + saveSettings, + validateSettings, + type LivingLineSettings, +} from './settings'; + +export const LIVING_LINE_SETTINGS_EVENT = 'openbitfun:living-line-settings'; +export const LIVING_LINE_SETTINGS_CHANNEL = 'openbitfun-living-line-settings'; + +function getStorage(): Storage | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +export function readLivingLineSettings(): LivingLineSettings { + const storage = getStorage(); + if (!storage) return { ...DEFAULT_SETTINGS }; + try { + const raw = storage.getItem(LIVING_LINE_SETTINGS_KEY); + if (!raw) return { ...DEFAULT_SETTINGS }; + return validateSettings(JSON.parse(raw)); + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveLivingLineSettings(patch: Partial): LivingLineSettings { + const storage = getStorage(); + if (!storage) throw new Error('LivingLine settings storage is unavailable'); + const next = validateSettings({ ...readLivingLineSettings(), ...patch }); + storage.setItem(LIVING_LINE_SETTINGS_KEY, JSON.stringify(next)); + + try { + window.dispatchEvent(new CustomEvent(LIVING_LINE_SETTINGS_EVENT)); + } catch { + /* Event dispatch is best-effort; persisted settings remain authoritative. */ + } + + try { + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(LIVING_LINE_SETTINGS_CHANNEL); + channel.postMessage('changed'); + channel.close(); + } + } catch { + /* Cross-window broadcast is optional. */ + } + + return next; +} + +export function subscribeLivingLineSettings(listener: () => void): () => void { + if (typeof window === 'undefined') return () => {}; + + const handleStorage = (event: StorageEvent) => { + if (event.key === null || event.key === LIVING_LINE_SETTINGS_KEY) listener(); + }; + const handleCustom = () => listener(); + const handleFocus = () => listener(); + + window.addEventListener('storage', handleStorage); + window.addEventListener(LIVING_LINE_SETTINGS_EVENT, handleCustom); + window.addEventListener('focus', handleFocus); + + let channel: BroadcastChannel | null = null; + try { + if (typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(LIVING_LINE_SETTINGS_CHANNEL); + channel.onmessage = handleCustom; + } + } catch { + channel = null; + } + + return () => { + window.removeEventListener('storage', handleStorage); + window.removeEventListener(LIVING_LINE_SETTINGS_EVENT, handleCustom); + window.removeEventListener('focus', handleFocus); + channel?.close(); + }; +} \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx b/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx new file mode 100644 index 0000000000..5ac3b2e487 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview-entry.tsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; +import LivingLinePreview from './preview'; + +const host = document.getElementById('living-line-root'); +if (!host) throw new Error('LivingLine preview root element is missing'); +createRoot(host).render(); diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.html b/src/web-ui/src/flow_chat/components/living-line/preview.html new file mode 100644 index 0000000000..13322e513f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.html @@ -0,0 +1,14 @@ + + + + + + + + LivingLine · 独立预览 + + +
+ + + diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.scss b/src/web-ui/src/flow_chat/components/living-line/preview.scss new file mode 100644 index 0000000000..1cf9e1589d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.scss @@ -0,0 +1,26 @@ +.living-line-preview { + min-height: 100vh; padding: 8vh 8vw; box-sizing: border-box; color: #f3f5f3; + background: radial-gradient(ellipse at 50% 42%, #202d31 0%, #111719 52%, #0c1012 100%); + font: 14px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; + header { display: flex; align-items: center; gap: 16px; max-width: 900px; margin: auto; } + header p, .living-line-caption { color: #91a4a3; font-size: 10px; letter-spacing: .18em; } + h1 { margin: 0; font-size: 28px; font-weight: 500; letter-spacing: -.04em; } + header p { margin: 0 0 3px; } + .living-line-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid #597374; border-radius: 50%; color: #8fe0d4; } + .living-line-stage { max-width: 900px; min-height: 300px; margin: 8vh auto 32px; padding: 40px; display: grid; place-items: center; border: 1px solid #ffffff15; border-radius: 16px; background: #ffffff06; } + .living-line-art { width: min(100%, 600px); overflow: visible; path { fill: none; stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 7px currentColor); } .state-ring { fill: none; stroke-width: 3.2; filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 0 9px currentColor); } .state-glow { filter: blur(5px); } } + .is-hovering .main-path { visibility: hidden; } + .is-hovering .hover-path:first-of-type, .is-hovering .hover-path:last-of-type { opacity: .4; } + .living-line-caption { margin-top: 12px; } + .living-line-controls, .living-line-options { max-width: 900px; margin: 20px auto; display: flex; justify-content: center; gap: 10px; } + button { padding: 9px 20px; border: 1px solid #ffffff24; border-radius: 999px; background: #ffffff08; color: inherit; text-transform: capitalize; cursor: pointer; &:hover, &[aria-pressed='true'] { border-color: #80d8cd; color: #9de7dd; background: #80d8cd12; } } + .living-line-options { color: #a9b6b5; gap: 28px; label { cursor: pointer; } input { accent-color: #80d8cd; vertical-align: middle; } } + .living-line-settings { max-width: 900px; margin: 24px auto; padding: 16px 20px; border: 1px solid #ffffff20; border-radius: 12px; background: #ffffff06; color: #d9e3e1; } + .living-line-settings summary { cursor: pointer; color: #a9d8d2; } + .settings-colors { display: flex; flex-wrap: wrap; gap: 16px; margin: 18px 0; label { display: flex; align-items: center; gap: 8px; color: #a9b6b5; } input { width: 36px; height: 28px; border: 0; padding: 0; background: transparent; } } + .settings-ranges { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px 24px; label { display: grid; gap: 4px; color: #a9b6b5; } label span { display: flex; justify-content: space-between; } output { color: #d9e3e1; font-variant-numeric: tabular-nums; } input { width: 100%; accent-color: #80d8cd; } } + .settings-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; color: #91a4a3; font-size: 12px; } + footer { margin: 8vh auto 0; max-width: 900px; text-align: center; color: #586867; font-size: 12px; } +} +@media (max-width: 600px) { .living-line-preview { padding: 6vh 5vw; .living-line-stage { padding: 16px; } .living-line-options { flex-wrap: wrap; } } } +@media (prefers-reduced-motion: reduce) { .living-line-art { transition: none; } } diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.tsx b/src/web-ui/src/flow_chat/components/living-line/preview.tsx new file mode 100644 index 0000000000..ab5f601516 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/preview.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from 'react'; +import { HEIGHT, IDLE_WAVE_MODES, MARK, idleWaveLines, toPolyline, workingCoreStrokeWidth, workingHoverLines, WIDTH, type LivingState, type Point } from './geometry'; +import { approachFlowOpacity, sliceFlowHighlight } from './flow'; +import { DEFAULT_SETTINGS, validateSettings, type LivingLineSettings } from './settings'; +import { readLivingLineSettings, saveLivingLineSettings, subscribeLivingLineSettings } from './livingLineSettingsStore'; +import { LivingLineEngine } from './engine'; +import './preview.scss'; + +type RangeKey = Exclude; +const RANGE_CONTROLS: readonly { key: RangeKey; label: string; min: number; max: number; step: number }[] = [ + { key: 'lineWidth', label: 'Main line width', min: 1, max: 8, step: 0.1 }, + { key: 'flowLength', label: 'Flow length', min: 6, max: 64, step: 1 }, + { key: 'flowWidth', label: 'Flow core width', min: 0.8, max: 6, step: 0.1 }, + { key: 'flowBlur', label: 'Flow blur', min: 0, max: 16, step: 0.5 }, + { key: 'flowIntensity', label: 'Flow intensity', min: 0, max: 1, step: 0.05 }, + { key: 'lineGlowBlur', label: 'Ambient glow', min: 0, max: 24, step: 1 }, + { key: 'idlePeriodSeconds', label: 'Idle flow period (seconds)', min: 2, max: 24, step: 0.5 }, + { key: 'workingPeriodSeconds', label: 'Working flow period (seconds)', min: 0.5, max: 10, step: 0.1 }, +]; + +function rgba(hex: string, opacity: number): string { + const value = Number.parseInt(hex.slice(1), 16); + return `rgba(${value >> 16}, ${(value >> 8) & 255}, ${value & 255}, ${opacity})`; +} + +export function LivingLineRenderer({ state, reducedMotion, settings, hovered = false, className = '' }: { state: LivingState; reducedMotion: boolean; settings: LivingLineSettings; hovered?: boolean; className?: string }) { + const engine = useRef(null); + if (!engine.current) engine.current = new LivingLineEngine(); + const main = useRef(null); + const hoverPaths = useRef<(SVGPathElement | null)[]>([]); + const flowPaths = useRef<(SVGPathElement | null)[]>([]); + const ring = useRef(null); + const glow = useRef(null); + const options = useRef({ state, hoverEnabled: false, reducedMotion, settings }); + const [pointerOver, setPointerOver] = useState(false); + const hoverProgress = useRef(0); + const idleSplitProgress = useRef(0); + const hoverTarget = useRef(false); + const waveMode = useRef(0); + const waveFrom = useRef(0); + const waveTransitionStart = useRef(0); + const nextWaveChange = useRef(0); + const flowProgress = useRef(0); + const flowSpeed = useRef(0); + const flowOpacity = useRef([0, 0, 0]); + options.current = { state, hoverEnabled: hovered || pointerOver, reducedMotion, settings }; + hoverTarget.current = pointerOver && !reducedMotion; + useEffect(() => { engine.current!.setState(state); }, [state]); + useEffect(() => { engine.current!.setReducedMotion(reducedMotion); }, [reducedMotion]); + useEffect(() => { + let frame = 0; + let previous = 0; + let phase = 0; + let visible = !document.hidden; + const render = (now: number) => { + if (visible) { + if (previous) { + const delta = Math.min((now - previous) / 1000, 1 / 30); + const points = engine.current!.step(delta); + const d = toPolyline(points); + const config = options.current.settings; + const currentState = options.current.state; + const activeColor = currentState === 'question' ? config.questionColor : currentState === 'error' ? config.errorColor : config.lineColor; + main.current?.setAttribute('stroke', activeColor); + main.current?.style.setProperty('filter', config.lineGlowBlur ? `drop-shadow(0 0 ${config.lineGlowBlur}px ${rgba(activeColor, 0.58)})` : 'none'); + const dt = delta; + hoverTarget.current = options.current.hoverEnabled && !options.current.reducedMotion; + const splitTarget = currentState === 'working' && hoverTarget.current ? 1 : 0; + hoverProgress.current += (splitTarget - hoverProgress.current) * (1 - Math.exp(-dt / 0.24)); + const idleSplitTarget = currentState === 'idle' && hoverTarget.current ? 1 : 0; + idleSplitProgress.current += (idleSplitTarget - idleSplitProgress.current) * (1 - Math.exp(-dt / 0.28)); + if (!options.current.reducedMotion && currentState === 'idle' && now >= nextWaveChange.current) { + waveFrom.current = waveMode.current; + waveMode.current = (waveMode.current + 1 + Math.floor(Math.random() * (IDLE_WAVE_MODES.length - 1))) % IDLE_WAVE_MODES.length; + waveTransitionStart.current = now; + nextWaveChange.current = now + 4200 + Math.random() * 3800; + } + let lanes: Point[][] = []; + if (currentState === 'working') lanes = workingHoverLines(points, phase, hoverProgress.current); + if (currentState === 'idle') { + const blend = Math.min(1, Math.max(0, (now - waveTransitionStart.current) / 1000)); + const eased = blend * blend * (3 - 2 * blend); + const from = IDLE_WAVE_MODES[waveFrom.current]; + const to = IDLE_WAVE_MODES[waveMode.current]; + const mode = { + cycles: from.cycles + (to.cycles - from.cycles) * eased, + amplitude: from.amplitude + (to.amplitude - from.amplitude) * eased, + secondCycles: from.secondCycles + (to.secondCycles - from.secondCycles) * eased, + secondAmplitude: from.secondAmplitude + (to.secondAmplitude - from.secondAmplitude) * eased, + secondOpacity: from.secondOpacity + (to.secondOpacity - from.secondOpacity) * eased, + }; + lanes = idleWaveLines(points, phase, mode, idleSplitProgress.current); + main.current?.setAttribute('d', toPolyline(lanes[0])); + if (idleSplitProgress.current > 0.01) { + lanes.slice(1).forEach((line, index) => { + const node = hoverPaths.current[index]; + node?.setAttribute('d', toPolyline(line)); node?.setAttribute('visibility', 'visible'); + node?.setAttribute('stroke', activeColor); node?.style.setProperty('stroke-width', `${config.lineWidth * 0.56}px`); + node?.setAttribute('opacity', String(idleSplitProgress.current * 0.72)); + }); + hoverPaths.current.slice(2).forEach(node => node?.setAttribute('visibility', 'hidden')); + } else { + const second = lanes[1]; + hoverPaths.current[0]?.setAttribute('visibility', second ? 'visible' : 'hidden'); + if (second) hoverPaths.current[0]?.setAttribute('d', toPolyline(second)); + hoverPaths.current[0]?.setAttribute('stroke', activeColor); + hoverPaths.current[0]?.style.setProperty('stroke-width', `${config.lineWidth}px`); + hoverPaths.current[0]?.setAttribute('opacity', String(mode.secondOpacity)); + hoverPaths.current.slice(1).forEach(node => node?.setAttribute('visibility', 'hidden')); + } + main.current?.setAttribute('visibility', 'visible'); + } else { + main.current?.setAttribute('d', d); + const coreWidth = workingCoreStrokeWidth(hoverProgress.current, config.lineWidth); + main.current?.style.setProperty('stroke-width', `${coreWidth}px`); + lanes.forEach((line, index) => { + const node = hoverPaths.current[index]; + node?.setAttribute('d', toPolyline(line)); node?.setAttribute('stroke', activeColor); + node?.setAttribute('visibility', currentState === 'working' && hoverProgress.current > 0.001 ? 'visible' : 'hidden'); + node?.style.setProperty('stroke-width', index === 1 ? `${coreWidth}px` : `${config.lineWidth * 0.56}px`); + node?.setAttribute('opacity', String(index === 1 ? 1 : 0.5 * hoverProgress.current)); + }); + hoverPaths.current.forEach((node, index) => { if (index >= lanes.length) node?.setAttribute('visibility', 'hidden'); }); + } + main.current?.setAttribute('visibility', currentState === 'working' && hoverProgress.current > 0.001 ? 'hidden' : 'visible'); + main.current?.style.setProperty('stroke-width', `${currentState === 'working' ? workingCoreStrokeWidth(hoverProgress.current, config.lineWidth) : config.lineWidth}px`); + const radius = engine.current!.getMarkRadius(); + ring.current?.setAttribute('stroke', activeColor); ring.current?.style.setProperty('stroke-width', `${config.lineWidth}px`); + ring.current?.setAttribute('r', String(radius)); ring.current?.setAttribute('visibility', radius < 0.15 ? 'hidden' : 'visible'); + glow.current?.setAttribute('fill', activeColor); glow.current?.setAttribute('r', String(radius * 1.8)); + glow.current?.setAttribute('opacity', String(Math.min(0.22, radius / MARK.errorRadius * 0.22))); + + const flowTargets = currentState === 'idle' + ? idleSplitProgress.current > 0.01 ? lanes : lanes.slice(0, IDLE_WAVE_MODES[waveMode.current].secondOpacity > 0.001 ? 2 : 1) + : currentState === 'working' && hoverProgress.current > 0.01 ? lanes : [points]; + const targetSpeed = 1 / (currentState === 'working' ? config.workingPeriodSeconds : currentState === 'question' || currentState === 'error' ? Math.max(config.idlePeriodSeconds * 0.7, 3) : config.idlePeriodSeconds); + flowSpeed.current += (targetSpeed - flowSpeed.current) * (1 - Math.exp(-dt / 0.3)); + if (!options.current.reducedMotion) flowProgress.current += dt * flowSpeed.current; + flowPaths.current.forEach((node, index) => { + const target = flowTargets[index]; + const progress = (flowProgress.current + index * 0.14) % 1; + const segment = target ? sliceFlowHighlight(target, progress, config.flowLength) : []; + const targetActive = Boolean(segment.length && config.flowIntensity > 0 && !options.current.reducedMotion); + flowOpacity.current[index] = approachFlowOpacity(flowOpacity.current[index], targetActive, dt); + if (!node) return; + if (segment.length) node.setAttribute('d', toPolyline(segment)); + node.setAttribute('visibility', flowOpacity.current[index] > 0.005 ? 'visible' : 'hidden'); + node.setAttribute('stroke', config.flowColor); node.style.setProperty('stroke-width', `${config.flowWidth}px`); + node.setAttribute('opacity', String(config.flowIntensity * flowOpacity.current[index])); + node.style.setProperty('filter', config.flowBlur ? `drop-shadow(0 0 ${config.flowBlur}px ${rgba(config.flowColor, config.flowIntensity * flowOpacity.current[index])})` : 'none'); + }); + if (!options.current.reducedMotion) phase += (now - previous) / 1000 * 2.4; + } + previous = now; + } + frame = requestAnimationFrame(render); + }; + const onVisibility = () => { visible = !document.hidden; previous = 0; }; + document.addEventListener('visibilitychange', onVisibility); + frame = requestAnimationFrame(render); + return () => { cancelAnimationFrame(frame); document.removeEventListener('visibilitychange', onVisibility); }; + }, []); + const stroke = state === 'question' ? settings.questionColor : state === 'error' ? settings.errorColor : settings.lineColor; + const initialD = toPolyline(engine.current.getPoints()); + return { if (event.pointerType === 'mouse') setPointerOver(true); }} onMouseEnter={() => setPointerOver(true)} onPointerLeave={() => setPointerOver(false)} onMouseLeave={() => setPointerOver(false)}> + + {Array.from({ length: 3 }, (_, index) => { hoverPaths.current[index] = node; }} className="hover-path" d={initialD} stroke={stroke} visibility="hidden" />)} + {Array.from({ length: 3 }, (_, index) => { flowPaths.current[index] = node; }} className="flow-path" visibility="hidden" />)} + + + ; +} + +export default function LivingLinePreview() { + const [settings, setSettings] = useState(readLivingLineSettings); + const [settingsStatus, setSettingsStatus] = useState('Using local settings'); + const [state, setState] = useState('idle'); + const [reducedMotion, setReducedMotion] = useState(false); + useEffect(() => subscribeLivingLineSettings(() => { + setSettings(readLivingLineSettings()); + setSettingsStatus('Local settings synchronized'); + }), []); + const update = (key: keyof LivingLineSettings, value: string | number) => { + try { + const next = validateSettings({ ...settings, [key]: value }); + setSettings(saveLivingLineSettings(next)); + setSettingsStatus('Saved'); + } catch (error) { setSettingsStatus(error instanceof Error ? error.message : 'Invalid settings'); } + }; + const reset = () => { + try { + setSettings(saveLivingLineSettings({ ...DEFAULT_SETTINGS })); + setSettingsStatus('Defaults restored'); + } catch { setSettingsStatus('Save failed: local storage is unavailable'); } + }; + return
LL

INDEPENDENT MOTION STUDY

LivingLine

+
{state.toUpperCase()} / VECTOR STATE
+ +
Move the pointer over the canvas to preview the split animation
+
Flow and line preview settings +
{([['lineColor', 'Idle/Working line color'], ['questionColor', 'Question color'], ['errorColor', 'Error color'], ['flowColor', 'Flow color']] as const).map(([key, label]) => )}
+
{RANGE_CONTROLS.map(item => )}
+
{settingsStatus}
+
+
LivingLine · Original SVG state and damping engine
; +} diff --git a/src/web-ui/src/flow_chat/components/living-line/settings.test.ts b/src/web-ui/src/flow_chat/components/living-line/settings.test.ts new file mode 100644 index 0000000000..eadad3efd9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/settings.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SETTINGS, LIVING_LINE_SETTINGS_KEY, loadSettings, saveSettings, validateSettings } from './settings'; + +describe('LivingLine preview settings', () => { + it('uses an isolated versioned storage key and defaults', () => { + expect(LIVING_LINE_SETTINGS_KEY).toBe('openbitfun.living-line.settings.v1'); + expect(validateSettings({})).toEqual(DEFAULT_SETTINGS); + }); + it('rejects non-finite, out-of-range and invalid color values', () => { + expect(() => validateSettings({ flowBlur: Number.NaN })).toThrow(); + expect(() => validateSettings({ idlePeriodSeconds: 0 })).toThrow(); + expect(() => validateSettings({ lineColor: 'red' })).toThrow(); + expect(() => validateSettings({ extra: 1 })).toThrow(); + }); + it('round-trips validated settings and does not persist invalid values', () => { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } }; + const candidate = { ...DEFAULT_SETTINGS, flowIntensity: 0.55, flowColor: '#FFFFFF' }; + expect(saveSettings(storage, candidate)).toBe(true); + expect(loadSettings(storage)).toEqual({ settings: candidate, status: 'loaded' }); + expect(saveSettings(storage, { ...candidate, flowLength: Infinity })).toBe(false); + expect(loadSettings(storage).settings).toEqual(candidate); + }); + it('falls back clearly for corrupt persisted data and unavailable storage', () => { + expect(loadSettings({ getItem: () => '{bad', setItem: () => {} })).toEqual({ settings: DEFAULT_SETTINGS, status: 'invalid' }); + expect(loadSettings({ getItem: () => { throw new Error('denied'); }, setItem: () => {} }).status).toBe('unavailable'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/living-line/settings.ts b/src/web-ui/src/flow_chat/components/living-line/settings.ts new file mode 100644 index 0000000000..ea5e53d6c2 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/living-line/settings.ts @@ -0,0 +1,63 @@ +export const LIVING_LINE_SETTINGS_KEY = 'openbitfun.living-line.settings.v1'; + +export type LivingLineSettings = { + lineColor: string; + questionColor: string; + errorColor: string; + flowColor: string; + lineWidth: number; + flowLength: number; + flowWidth: number; + flowBlur: number; + flowIntensity: number; + lineGlowBlur: number; + idlePeriodSeconds: number; + workingPeriodSeconds: number; +}; + +type StorageLike = Pick; +export const DEFAULT_SETTINGS: Readonly = { + lineColor: '#9FE8FF', questionColor: '#FFD45A', errorColor: '#FF8D9A', flowColor: '#FFF8DC', + lineWidth: 3.2, flowLength: 24, flowWidth: 2.4, flowBlur: 4, flowIntensity: 0.9, + lineGlowBlur: 7, idlePeriodSeconds: 8, workingPeriodSeconds: 1.8, +}; + +export const RANGES: Record, readonly [number, number]> = { + lineWidth: [1, 8], flowLength: [6, 64], flowWidth: [0.8, 6], flowBlur: [0, 16], + flowIntensity: [0, 1], lineGlowBlur: [0, 24], idlePeriodSeconds: [2, 24], workingPeriodSeconds: [0.5, 10], +}; + +export function validateSettings(value: unknown): LivingLineSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Settings must be an object'); + const input = value as Record; + const allowed = new Set([...Object.keys(DEFAULT_SETTINGS)]); + if (Object.keys(input).some(key => !allowed.has(key))) throw new Error('Settings contain an unknown field'); + const result = { ...DEFAULT_SETTINGS, ...input } as LivingLineSettings; + for (const key of ['lineColor', 'questionColor', 'errorColor', 'flowColor'] as const) { + if (typeof result[key] !== 'string' || !/^#[\da-f]{6}$/i.test(result[key])) throw new Error(`Invalid color: ${key}`); + } + for (const [key, [min, max]] of Object.entries(RANGES) as [keyof typeof RANGES, readonly [number, number]][]) { + const number = result[key]; + if (typeof number !== 'number' || !Number.isFinite(number) || number < min || number > max) throw new Error(`Value is out of range: ${key}`); + } + return result; +} + +export function loadSettings(storage: StorageLike): { settings: LivingLineSettings; status: 'default' | 'loaded' | 'invalid' | 'unavailable' } { + let raw: string | null; + try { raw = storage.getItem(LIVING_LINE_SETTINGS_KEY); } + catch { return { settings: { ...DEFAULT_SETTINGS }, status: 'unavailable' }; } + if (!raw) return { settings: { ...DEFAULT_SETTINGS }, status: 'default' }; + try { return { settings: validateSettings(JSON.parse(raw)), status: 'loaded' }; } + catch { return { settings: { ...DEFAULT_SETTINGS }, status: 'invalid' }; } +} + +export function saveSettings(storage: StorageLike, value: unknown): boolean { + try { + const validated = validateSettings(value); + storage.setItem(LIVING_LINE_SETTINGS_KEY, JSON.stringify(validated)); + return true; + } catch { + return false; + } +} diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index d1151f5e53..66c4fdd255 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -3,7 +3,7 @@ import { flowChatStore } from '../store/FlowChatStore'; import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; import { ProcessingPhase, SessionExecutionEvent, SessionExecutionState } from '../state-machine/types'; import type { DialogTurn, Session } from '../types/flow-chat'; -import { buildAgentCompanionActivity } from './agentCompanionActivity'; +import { buildAgentCompanionActivity, deriveLivingLineState } from './agentCompanionActivity'; const background = vi.hoisted(() => ({ rows: [] as unknown[] })); vi.mock('../services/crossDeviceSessions', () => ({ @@ -195,6 +195,18 @@ function createAskUserQuestionSessionWithTrailingText(): Session { return session; } +describe('LivingLine activity adapter', () => { + const base = { title: 'Session', mood: 'rest' as const, labelKey: '', defaultLabel: '', startedAt: 0, updatedAt: 0 }; + const status = (sessionId: string, state: 'running' | 'waiting' | 'attention' | 'completed' | 'error' | 'interrupted') => ({ ...base, sessionId, state }); + + it('prioritizes errors across all tasks, then questions, active work, and idle', () => { + expect(deriveLivingLineState([status('a', 'running'), status('b', 'attention')])).toBe('question'); + expect(deriveLivingLineState([status('a', 'completed'), status('b', 'running')])).toBe('working'); + expect(deriveLivingLineState([status('a', 'completed')])).toBe('idle'); + expect(deriveLivingLineState([...Array.from({ length: 4 }, (_, index) => status(String(index), 'completed')), status('late-error', 'error')])).toBe('error'); + }); +}); + describe('buildAgentCompanionActivity', () => { afterEach(() => { resetState(); @@ -230,6 +242,7 @@ describe('buildAgentCompanionActivity', () => { expect(buildAgentCompanionActivity()).toEqual({ mood: 'rest', tasks: [], + livingLineState: 'idle', }); }); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 174614764c..374ac61c71 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -53,9 +53,13 @@ export interface AgentCompanionTaskStatus { deviceName?: string; } +export type LivingLineActivityState = 'idle' | 'working' | 'question' | 'error'; + export interface AgentCompanionActivityPayload { mood: AgentCompanionMood; tasks: AgentCompanionTaskStatus[]; + /** Persistent semantic state; independent of transient mood and bubble visibility. */ + livingLineState?: LivingLineActivityState; sequence?: number; emittedAt?: number; } @@ -63,6 +67,7 @@ export interface AgentCompanionActivityPayload { const EMPTY_ACTIVITY: AgentCompanionActivityPayload = { mood: 'rest', tasks: [], + livingLineState: 'idle', }; const taskOrderBySessionId = new Map(); @@ -355,6 +360,13 @@ function taskStableOrder(task: AgentCompanionTaskStatus): number { return ensureTaskOrder(task.sessionId); } +export function deriveLivingLineState(tasks: readonly AgentCompanionTaskStatus[]): LivingLineActivityState { + if (tasks.some(task => task.state === 'error')) return 'error'; + if (tasks.some(task => task.state === 'attention')) return 'question'; + if (tasks.some(task => task.state === 'running' || task.state === 'waiting')) return 'working'; + return 'idle'; +} + function aggregateMood(tasks: AgentCompanionTaskStatus[]): AgentCompanionMood { if (tasks.some(task => task.mood === 'waiting')) { return 'waiting'; @@ -490,6 +502,7 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { return { mood: aggregateMood(sortedTasks), tasks: sortedTasks, + livingLineState: deriveLivingLineState(tasks), }; } diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 1f9ff26dfb..7f2525e831 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -135,6 +135,7 @@ import { import { settingsNavAppearanceDescriptor } from '@/app/scenes/settings/SettingsNav.appearance'; import { backgroundCommandOutputPanelAppearanceDescriptor } from '@/flow_chat/components/background-command/BackgroundCommandOutputPanel.appearance'; import { agentCompanionPetAppearanceDescriptor } from '@/flow_chat/components/AgentCompanionPet.appearance'; +import { livingLinePreviewAppearanceDescriptor } from '@/flow_chat/components/living-line/appearance'; import { chatContextPickerAppearanceDescriptor, legacyFileMentionPickerAppearanceDescriptor, @@ -409,6 +410,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(automationSettingsPageAppearanceDescriptor) .registerComponent(backgroundCommandOutputPanelAppearanceDescriptor) .registerComponent(agentCompanionPetAppearanceDescriptor) +.registerComponent(livingLinePreviewAppearanceDescriptor) .registerComponent(chatContextPickerAppearanceDescriptor) .registerComponent(legacyFileMentionPickerAppearanceDescriptor) .registerComponent(sessionFileModificationsBarAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts index f7a2cd636d..37cfb57d71 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts @@ -24,6 +24,7 @@ describe('AgentCompanionPetService built-in presets', () => { const pets = await listAgentCompanionPets(); const blueGolden = pets.find(pet => pet.id === 'blue-golden'); const openbitfun = pets.find(pet => pet.id === 'openbitfun'); + const livingLine = pets.find(pet => pet.id === 'living-line'); expect(DEFAULT_AGENT_COMPANION_PET).toMatchObject({ id: 'bitblob', @@ -55,6 +56,12 @@ describe('AgentCompanionPetService built-in presets', () => { packagePath: '/agent-companion-pets/openbitfun', spritesheetPath: '/agent-companion-pets/openbitfun/spritesheet.webp', }); + expect(livingLine).toMatchObject({ + displayName: 'LivingLine', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', + previewSrc: '/agent-companion-pets/living-line/preview.svg', + }); expect(invoke).not.toHaveBeenCalled(); }); diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index cdf80754cc..8e9c925338 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -5,6 +5,7 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; import { createLogger } from '@/shared/utils/logger'; import builtinPetMetadata from './agentCompanionBuiltinPetMetadata.json'; import { getPetSpriteLayout } from './agentCompanionPetSprite'; +import { LIVING_LINE_PET } from './livingLinePetIdentity'; const log = createLogger('AgentCompanionPetService'); const BUILTIN_PET_BASE = '/agent-companion-pets'; @@ -230,6 +231,7 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [ spritesheetMimeType: 'image/webp', }, GROK_LIVE_PET, + LIVING_LINE_PET, ]; export interface AgentCompanionPetPackage extends AgentCompanionPetSelection { diff --git a/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts new file mode 100644 index 0000000000..b8b4e0e2f9 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { LIVING_LINE_PET, isLivingLinePet } from './livingLinePetIdentity'; + +describe('LivingLine pet identity', () => { + it('recognizes only the bundled preset identity', () => { + expect(isLivingLinePet(LIVING_LINE_PET)).toBe(true); + expect(isLivingLinePet({ ...LIVING_LINE_PET, source: 'user' })).toBe(false); + expect(isLivingLinePet({ ...LIVING_LINE_PET, packagePath: '/other/living-line' })).toBe(false); + expect(isLivingLinePet(null)).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts new file mode 100644 index 0000000000..15d11668b4 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/livingLinePetIdentity.ts @@ -0,0 +1,16 @@ +import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; + +export const LIVING_LINE_PET: AgentCompanionPetSelection = { + id: 'living-line', + displayName: 'LivingLine', + description: 'An original animated line companion with expressive activity states.', + source: 'preset', + packagePath: '/agent-companion-pets/living-line', + spritesheetPath: '/agent-companion-pets/living-line/preview.svg', + spritesheetMimeType: 'image/svg+xml', +}; + +export function isLivingLinePet(pet: AgentCompanionPetSelection | null | undefined): boolean { + return pet?.source === 'preset' && pet.id === LIVING_LINE_PET.id + && pet.packagePath === LIVING_LINE_PET.packagePath; +} diff --git a/src/web-ui/src/locales/en-US/settings/runtime.json b/src/web-ui/src/locales/en-US/settings/runtime.json index 90398915af..9fc8df7ec9 100644 --- a/src/web-ui/src/locales/en-US/settings/runtime.json +++ b/src/web-ui/src/locales/en-US/settings/runtime.json @@ -54,6 +54,22 @@ "eyeColor.lightGray": "Light gray", "eyeColor.custom": "Custom" }, + "livingLine": { + "title": "LivingLine settings", + "reset": "Restore defaults", + "lineColor": "Line color", + "questionColor": "Question color", + "errorColor": "Error color", + "flowColor": "Flow color", + "lineWidth": "Line width", + "flowLength": "Flow length", + "flowWidth": "Flow width", + "flowBlur": "Flow blur", + "flowIntensity": "Flow intensity", + "lineGlowBlur": "Line glow blur", + "idlePeriodSeconds": "Idle period (seconds)", + "workingPeriodSeconds": "Working period (seconds)" + }, "presets": { "blueGolden": { "name": "困困" diff --git a/src/web-ui/src/locales/zh-CN/settings/runtime.json b/src/web-ui/src/locales/zh-CN/settings/runtime.json index fff5062c2f..7640c50af7 100644 --- a/src/web-ui/src/locales/zh-CN/settings/runtime.json +++ b/src/web-ui/src/locales/zh-CN/settings/runtime.json @@ -54,6 +54,22 @@ "eyeColor.lightGray": "浅灰色", "eyeColor.custom": "自定义" }, + "livingLine": { + "title": "LivingLine 设置", + "reset": "恢复默认", + "lineColor": "线条颜色", + "questionColor": "提问颜色", + "errorColor": "错误颜色", + "flowColor": "流动颜色", + "lineWidth": "线条宽度", + "flowLength": "流动长度", + "flowWidth": "流动宽度", + "flowBlur": "流动模糊", + "flowIntensity": "流动强度", + "lineGlowBlur": "线条发光模糊", + "idlePeriodSeconds": "闲置周期(秒)", + "workingPeriodSeconds": "工作周期(秒)" + }, "presets": { "blueGolden": { "name": "困困" diff --git a/src/web-ui/src/locales/zh-TW/settings/runtime.json b/src/web-ui/src/locales/zh-TW/settings/runtime.json index aafe1a505d..0cb855a461 100644 --- a/src/web-ui/src/locales/zh-TW/settings/runtime.json +++ b/src/web-ui/src/locales/zh-TW/settings/runtime.json @@ -54,6 +54,22 @@ "eyeColor.lightGray": "淺灰色", "eyeColor.custom": "自訂" }, + "livingLine": { + "title": "LivingLine 設定", + "reset": "恢復預設", + "lineColor": "線條顏色", + "questionColor": "提問顏色", + "errorColor": "錯誤顏色", + "flowColor": "流動顏色", + "lineWidth": "線條寬度", + "flowLength": "流動長度", + "flowWidth": "流動寬度", + "flowBlur": "流動模糊", + "flowIntensity": "流動強度", + "lineGlowBlur": "線條發光模糊", + "idlePeriodSeconds": "閒置週期(秒)", + "workingPeriodSeconds": "工作週期(秒)" + }, "presets": { "blueGolden": { "name": "困困" From d8a310fc6211a187afdf0f8630b3c67a80138aa0 Mon Sep 17 00:00:00 2001 From: Stsirywtbd <1660237548@qq.com> Date: Sat, 7 Nov 2026 19:47:43 +0800 Subject: [PATCH 4/4] chore: tune living-line frame size and spring parameters --- .../AgentCompanionDesktopPet.test.tsx | 11 +++++----- .../AgentCompanionDesktopPet.tsx | 4 ++-- .../DesktopCardsSettingsSection.test.tsx | 15 +++++++++++++ .../components/living-line/LivingLinePet.scss | 6 +++--- .../living-line/LivingLinePet.test.tsx | 5 +++++ .../components/living-line/LivingLinePet.tsx | 2 +- .../components/living-line/engine.test.ts | 21 ++++++++++++++----- .../components/living-line/engine.ts | 7 +++++-- .../components/living-line/geometry.ts | 12 +++++++---- .../components/living-line/preview.tsx | 17 ++++++++++----- 10 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx index 1e88c6dc7d..0b3fb7e2f4 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx @@ -215,11 +215,12 @@ describe('AgentCompanionDesktopPet', () => { root.render(); }); // Reserve the bubble slot before there is a task, not after it paints. - expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 480, height: 198 }); + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 408, height: 102 }); invokeMock.mockClear(); pushActivity({ mood: 'rest', tasks: [task({ state: 'error', mood: 'rest' })], livingLineState: 'error', emittedAt: 1, sequence: 1 }); - expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-width: 288px'); + expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-width: 216px'); + expect(query('.openbitfun-agent-companion-window__stack')?.getAttribute('style')).toContain('--openbitfun-agent-companion-pet-height: 96px'); expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-living-line-state')).toBe('error'); pushActivity({ mood: 'working', tasks: [task(), task({ sessionId: 'second' })], emittedAt: 2, sequence: 2 }); pushActivity({ mood: 'rest', tasks: [], emittedAt: 3, sequence: 3 }); @@ -229,7 +230,7 @@ describe('AgentCompanionDesktopPet', () => { }); it('waits for the LivingLine viewport before revealing bubbles and preserves their nodes', async () => { - vi.stubGlobal('innerWidth', 288); + vi.stubGlobal('innerWidth', 216); act(() => window.dispatchEvent(new Event('resize'))); const settingsListener = listenMock.mock.calls.find(([name]) => name === 'agent-companion://settings-updated')?.[1]; await act(async () => settingsListener({ payload: { @@ -245,9 +246,9 @@ describe('AgentCompanionDesktopPet', () => { const bubble = query('.openbitfun-agent-companion-window__bubble'); expect(bubbles.style.visibility).toBe('hidden'); expect(bubbles.getAttribute('aria-hidden')).toBe('true'); - expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 480, height: 198 }); + expect(invokeMock).toHaveBeenLastCalledWith('resize_agent_companion_desktop_pet', { width: 408, height: 102 }); - vi.stubGlobal('innerWidth', 480); + vi.stubGlobal('innerWidth', 408); act(() => window.dispatchEvent(new Event('resize'))); expect(bubbles.style.visibility).toBe(''); expect(bubbles.hasAttribute('aria-hidden')).toBe(false); diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 9d9424893f..dbd0ec252a 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -245,7 +245,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { const activePetSize = pet && petFrameSize ? petFrameSize : pet && isLivingLinePet(pet) - ? { width: 288, height: 192 } + ? { width: 216, height: 96 } : pet && isGrokLivePet(pet) ? { width: DEFAULT_PET_SIZE, height: DEFAULT_PET_SIZE } : pet @@ -501,7 +501,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { : activePetSize.height) + WINDOW_VERTICAL_BUFFER * 2; const measuredBubbleWidth = bubbleCount > 0 ? BUBBLE_WIDTH : 0; // Keep the wide renderer's bubble slot reserved even without a task. Native - // resize and reposition are asynchronous, so growing/shrinking by 192px on + // resize and reposition are asynchronous, so growing/shrinking by the pet frame height on // bubble arrival/removal otherwise exposes a clipped, left-shifted frame. const measuredDockWidth = livingLinePet ? BUBBLE_WIDTH + WINDOW_HORIZONTAL_GAP + activePetSize.width + WINDOW_EDGE_BUFFER diff --git a/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx b/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx index fe6d3fa14f..70db2b06f0 100644 --- a/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/application/DesktopCardsSettingsSection.test.tsx @@ -191,6 +191,21 @@ it('renders nothing to pin and never reads cards while the feature is off', asyn expect(mocks.list).not.toHaveBeenCalled(); }); +it('removes the pin row when the host deletes a card', async () => { + mocks.list.mockResolvedValue({ + enabled: true, + desktopPinningSupported: true, + cards: [card('review'), card('ordinary')], + }); + await render(); + expect(pinRows()).toHaveLength(2); + await act(async () => mocks.changed.forEach(listener => listener({ cardId: 'review', deleted: true }))); + expect(pinRows()).toHaveLength(1); + expect(pinRows()[0].textContent).toContain('Card ordinary'); + await act(async () => pinSwitch(pinRows()[0]).click()); + expect(mocks.setDesktopPinned).toHaveBeenCalledWith('ordinary', true); +}); + it('reports a rejected pin change without flipping the switch', async () => { mocks.list.mockResolvedValue({ enabled: true, diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss index 40fb0db475..4b584e17dc 100644 --- a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.scss @@ -1,7 +1,7 @@ .openbitfun-agent-companion-pet.living-line-pet { - width: 288px; - height: 192px; - flex: 0 0 288px; + width: 216px; + height: 96px; + flex: 0 0 216px; overflow: visible; pointer-events: none; } diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx index 626e645440..7643d3f8c7 100644 --- a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.test.tsx @@ -41,6 +41,11 @@ describe('LivingLine product settings integration', () => { act(() => root.render()); const svg = container.querySelector('svg'); const main = container.querySelector('.main-path')!; + expect(LIVING_LINE_FRAME_SIZE).toEqual({ width: 216, height: 96 }); + expect(svg?.getAttribute('viewBox')).toBe('32 29 192 85.33333333333333'); + const [, , viewWidth, viewHeight] = svg!.getAttribute('viewBox')!.split(' ').map(Number); + expect(LIVING_LINE_FRAME_SIZE.width / viewWidth).toBeCloseTo(288 / 256); + expect(LIVING_LINE_FRAME_SIZE.height / viewHeight).toBeCloseTo(288 / 256); expect(onFrame).toHaveBeenCalledExactlyOnceWith(LIVING_LINE_FRAME_SIZE); expect(requestFrame).toHaveBeenCalledTimes(1); expect(setState).toHaveBeenCalledTimes(1); diff --git a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx index 13540b2e66..8a906c1aab 100644 --- a/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx +++ b/src/web-ui/src/flow_chat/components/living-line/LivingLinePet.tsx @@ -5,7 +5,7 @@ import { readLivingLineSettings, subscribeLivingLineSettings } from './livingLin import type { LivingState } from './geometry'; import './LivingLinePet.scss'; -export const LIVING_LINE_FRAME_SIZE = { width: 288, height: 192 } as const; +export const LIVING_LINE_FRAME_SIZE = { width: 216, height: 96 } as const; export function LivingLinePet({ state, diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.test.ts b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts index fc99c76a27..18e9c3e585 100644 --- a/src/web-ui/src/flow_chat/components/living-line/engine.test.ts +++ b/src/web-ui/src/flow_chat/components/living-line/engine.test.ts @@ -1,16 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { GEOMETRY, IDLE_WAVE_MODES, idleWaveLines, MARK, MARK_RADIUS, SOURCE_PATHS, toPolyline, workingCoreStrokeWidth, workingHoverLines } from './geometry'; +import { GEOMETRY, IDLE_WAVE_MODES, idleWaveLines, MARK, MARK_RADIUS, SOURCE_PATHS, toPolyline, TRANSITION_SPRING, workingCoreStrokeWidth, workingHoverLines } from './geometry'; import { LivingLineEngine } from './engine'; describe('LivingLine geometry and engine', () => { it('uses the approved v0.4 SVG paths and fixed anchors', () => { - expect(SOURCE_PATHS.working).toContain('M38,84L76,84L83,80L90,84'); - expect(SOURCE_PATHS.question).toMatch(/^M38,84C/); + expect(SOURCE_PATHS.working).toContain('M60.5,84L76,84L83,80L90,84'); + expect(SOURCE_PATHS.question).toMatch(/^M60.5,84C/); expect(SOURCE_PATHS.question).toContain('149,53.25'); expect(SOURCE_PATHS.error).toContain('128,36'); + expect(GEOMETRY.working[0].x).toBe(60.5); + expect(GEOMETRY.working.at(-1)?.x).toBe(195.5); for (const state of ['working', 'question', 'error'] as const) { - expect(GEOMETRY[state][0]).toEqual({ x: 38, y: 84 }); - expect(GEOMETRY[state].at(-1)).toEqual({ x: 218, y: 84 }); + expect(GEOMETRY[state][0]).toEqual({ x: 60.5, y: 84 }); + expect(GEOMETRY[state].at(-1)).toEqual({ x: 195.5, y: 84 }); } }); it('flattens the approved paths to shared M/L vertices while retaining landmarks', () => { @@ -60,6 +62,15 @@ describe('LivingLine geometry and engine', () => { expect(toPolyline(points)).not.toMatch(/[CQASTZ]/i); } }); + it('uses a slower transition spring while preserving its damped response', () => { + expect(TRANSITION_SPRING).toEqual({ stiffness: 66.7, damping: 16 }); + const engine = new LivingLineEngine(); + engine.setState('working'); + for (let i = 0; i < 30; i++) engine.step(1 / 60); + expect(engine.settled).toBe(false); + for (let i = 0; i < 900; i++) engine.step(1 / 60); + expect(engine.settled).toBe(true); + }); it('keeps both endpoints fixed during updates', () => { const engine = new LivingLineEngine(); const before = engine.getPoints(); diff --git a/src/web-ui/src/flow_chat/components/living-line/engine.ts b/src/web-ui/src/flow_chat/components/living-line/engine.ts index a6cc5fc603..df6962c56c 100644 --- a/src/web-ui/src/flow_chat/components/living-line/engine.ts +++ b/src/web-ui/src/flow_chat/components/living-line/engine.ts @@ -1,4 +1,4 @@ -import { GEOMETRY, MARK_RADIUS, type LivingState, type Point } from './geometry'; +import { GEOMETRY, MARK_RADIUS, TRANSITION_SPRING, type LivingState, type Point } from './geometry'; type MutablePoint = { x: number; y: number }; @@ -14,7 +14,10 @@ export class LivingLineEngine { private readonly start: Point; private readonly end: Point; - constructor(private readonly stiffness = 150, private readonly damping = 24) { + constructor( + private readonly stiffness = TRANSITION_SPRING.stiffness, + private readonly damping = TRANSITION_SPRING.damping, + ) { this.current = GEOMETRY.idle.map(p => ({ ...p })); this.target = this.current.map(p => ({ ...p })); this.velocity = this.current.map(() => ({ x: 0, y: 0 })); diff --git a/src/web-ui/src/flow_chat/components/living-line/geometry.ts b/src/web-ui/src/flow_chat/components/living-line/geometry.ts index 7af8d2d6a0..5e161cf73d 100644 --- a/src/web-ui/src/flow_chat/components/living-line/geometry.ts +++ b/src/web-ui/src/flow_chat/components/living-line/geometry.ts @@ -4,10 +4,10 @@ type FlatPoint = { x: number; y: number }; // Path data is transcribed from the locally approved v0.4 SVG masters; cubic curves are flattened only for animation. export const SOURCE_PATHS: Record = { - idle: 'M38,84L218,84', - working: 'M38,84L76,84L83,80L90,84L103,84L109,89L116,58L123,95L130,84L148,84L159,79L170,84L218,84', - question: 'M38,84C66.60009765625,84,95.2001953125,84,123.7998046875,84C123.7998046875,81,123.7998046875,78,123.7998046875,75C123.7998046875,69,127.2998046875,66,132.89990234375,63C138.5,60,141.2998046875,57,141.2998046875,53.25C141.2998046875,47.25,135.7001953125,42.75,128.7001953125,42.75C121,42.75,115.39990234375,47.25,115.39990234375,54C115.39990234375,55.5,115.39990234375,57,115.39990234375,57C113.2998046875,57,110.5,57,108.39990234375,57C108.39990234375,56.25,108.39990234375,54.75,108.39990234375,54C108.39990234375,42.75,116.7998046875,35.25,128.7001953125,35.25C140.60009765625,35.25,149,42.75,149,53.25C149,61.5,144.7998046875,66,137.7998046875,69.75C133.60009765625,72,132.2001953125,74.25,132.2001953125,77.25C132.2001953125,79.5,132.2001953125,81.75,132.2001953125,84C160.7998046875,84,189.39990234375,84,218,84', - error: 'M38,84C66.39990234375,84,94.7998046875,84,123.2001953125,84C123.2001953125,72,122.2998046875,61,121.7001953125,51C121.10009765625,44,122.2998046875,36,128,36C133.7001953125,36,134.89990234375,44,134.2998046875,51C133.7001953125,61,132.7998046875,72,132.7998046875,84C161.2001953125,84,189.60009765625,84,218,84', + idle: 'M60.5,84L195.5,84', + working: 'M60.5,84L76,84L83,80L90,84L103,84L109,89L116,58L123,95L130,84L148,84L159,79L170,84L195.5,84', + question: 'M60.5,84C81.60009765625,84,102.7001953125,84,123.7998046875,84C123.7998046875,81,123.7998046875,78,123.7998046875,75C123.7998046875,69,127.2998046875,66,132.89990234375,63C138.5,60,141.2998046875,57,141.2998046875,53.25C141.2998046875,47.25,135.7001953125,42.75,128.7001953125,42.75C121,42.75,115.39990234375,47.25,115.39990234375,54C115.39990234375,55.5,115.39990234375,57,115.39990234375,57C113.2998046875,57,110.5,57,108.39990234375,57C108.39990234375,56.25,108.39990234375,54.75,108.39990234375,54C108.39990234375,42.75,116.7998046875,35.25,128.7001953125,35.25C140.60009765625,35.25,149,42.75,149,53.25C149,61.5,144.7998046875,66,137.7998046875,69.75C133.60009765625,72,132.2001953125,74.25,132.2001953125,77.25C132.2001953125,79.5,132.2001953125,81.75,132.2001953125,84C153.2998046875,84,174.39990234375,84,195.5,84', + error: 'M60.5,84C81.40009765625,84,102.3001953125,84,123.2001953125,84C123.2001953125,72,122.2998046875,61,121.7001953125,51C121.10009765625,44,122.2998046875,36,128,36C133.7001953125,36,134.89990234375,44,134.2998046875,51C133.7001953125,61,132.7998046875,72,132.7998046875,84C153.7001953125,84,174.60009765625,84,195.5,84', }; function flattenPath(path: string): FlatPoint[] { @@ -71,7 +71,11 @@ export const GEOMETRY: Record = { }; export const WIDTH = 256; export const HEIGHT = 144; +// At the 216px product frame this retains the original 288/256 artwork scale. +// Only the straight side regions shrink; the central glyphs keep their size. +export const VIEW_BOX = '32 29 192 85.33333333333333'; export const MARK = { cx: 128, cy: 106, questionRadius: 4.2, errorRadius: 4.8, strokeWidth: 3.2 } as const; +export const TRANSITION_SPRING = { stiffness: 66.7, damping: 16 } as const; export const MARK_RADIUS: Record = { idle: 0, working: 0, question: MARK.questionRadius, error: MARK.errorRadius }; export function toPolyline(points: readonly Point[]): string { diff --git a/src/web-ui/src/flow_chat/components/living-line/preview.tsx b/src/web-ui/src/flow_chat/components/living-line/preview.tsx index ab5f601516..2e92f7f165 100644 --- a/src/web-ui/src/flow_chat/components/living-line/preview.tsx +++ b/src/web-ui/src/flow_chat/components/living-line/preview.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { HEIGHT, IDLE_WAVE_MODES, MARK, idleWaveLines, toPolyline, workingCoreStrokeWidth, workingHoverLines, WIDTH, type LivingState, type Point } from './geometry'; +import { IDLE_WAVE_MODES, MARK, idleWaveLines, toPolyline, VIEW_BOX, workingCoreStrokeWidth, workingHoverLines, type LivingState, type Point } from './geometry'; import { approachFlowOpacity, sliceFlowHighlight } from './flow'; import { DEFAULT_SETTINGS, validateSettings, type LivingLineSettings } from './settings'; import { readLivingLineSettings, saveLivingLineSettings, subscribeLivingLineSettings } from './livingLineSettingsStore'; @@ -62,7 +62,10 @@ export function LivingLineRenderer({ state, reducedMotion, settings, hovered = f const currentState = options.current.state; const activeColor = currentState === 'question' ? config.questionColor : currentState === 'error' ? config.errorColor : config.lineColor; main.current?.setAttribute('stroke', activeColor); - main.current?.style.setProperty('filter', config.lineGlowBlur ? `drop-shadow(0 0 ${config.lineGlowBlur}px ${rgba(activeColor, 0.58)})` : 'none'); + const isAlertState = currentState === 'question' || currentState === 'error'; + const lineGlowBlur = isAlertState ? Math.min(config.lineGlowBlur, 8) : config.lineGlowBlur; + const lineGlowOpacity = isAlertState ? 0.7 : 0.58; + main.current?.style.setProperty('filter', lineGlowBlur ? `drop-shadow(0 0 ${lineGlowBlur}px ${rgba(activeColor, lineGlowOpacity)})` : 'none'); const dt = delta; hoverTarget.current = options.current.hoverEnabled && !options.current.reducedMotion; const splitTarget = currentState === 'working' && hoverTarget.current ? 1 : 0; @@ -127,8 +130,12 @@ export function LivingLineRenderer({ state, reducedMotion, settings, hovered = f const radius = engine.current!.getMarkRadius(); ring.current?.setAttribute('stroke', activeColor); ring.current?.style.setProperty('stroke-width', `${config.lineWidth}px`); ring.current?.setAttribute('r', String(radius)); ring.current?.setAttribute('visibility', radius < 0.15 ? 'hidden' : 'visible'); - glow.current?.setAttribute('fill', activeColor); glow.current?.setAttribute('r', String(radius * 1.8)); - glow.current?.setAttribute('opacity', String(Math.min(0.22, radius / MARK.errorRadius * 0.22))); + glow.current?.setAttribute('fill', activeColor); + const glowScale = currentState === 'question' || currentState === 'error' ? 1.45 : 1.8; + const glowPeak = currentState === 'question' || currentState === 'error' ? 0.3 : 0.22; + const glowBreathing = isAlertState ? 0.9 + Math.sin(now / 520) * 0.1 : 1; + glow.current?.setAttribute('r', String(radius * glowScale * glowBreathing)); + glow.current?.setAttribute('opacity', String(Math.min(glowPeak, radius / MARK.errorRadius * glowPeak) * glowBreathing)); const flowTargets = currentState === 'idle' ? idleSplitProgress.current > 0.01 ? lanes : lanes.slice(0, IDLE_WAVE_MODES[waveMode.current].secondOpacity > 0.001 ? 2 : 1) @@ -162,7 +169,7 @@ export function LivingLineRenderer({ state, reducedMotion, settings, hovered = f }, []); const stroke = state === 'question' ? settings.questionColor : state === 'error' ? settings.errorColor : settings.lineColor; const initialD = toPolyline(engine.current.getPoints()); - return { if (event.pointerType === 'mouse') setPointerOver(true); }} onMouseEnter={() => setPointerOver(true)} onPointerLeave={() => setPointerOver(false)} onMouseLeave={() => setPointerOver(false)}> + return { if (event.pointerType === 'mouse') setPointerOver(true); }} onMouseEnter={() => setPointerOver(true)} onPointerLeave={() => setPointerOver(false)} onMouseLeave={() => setPointerOver(false)}> {Array.from({ length: 3 }, (_, index) => { hoverPaths.current[index] = node; }} className="hover-path" d={initialD} stroke={stroke} visibility="hidden" />)} {Array.from({ length: 3 }, (_, index) => { flowPaths.current[index] = node; }} className="flow-path" visibility="hidden" />)}