From de2e00af15635bc8cf9b22f83d8bb2cd529002a9 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Thu, 2 Apr 2026 20:40:01 +0800 Subject: [PATCH] feat(flow-chat): pixel pet on chat input and session config - Add ChatInputPixelPet with mood logic and unit tests - Extend AI experience / session config (web + core types) - i18n for session settings (en-US, zh-CN) --- src/crates/core/src/service/config/types.rs | 3 + .../src/app/scenes/settings/settingsConfig.ts | 4 + .../src/flow_chat/components/ChatInput.scss | 92 ++++ .../src/flow_chat/components/ChatInput.tsx | 102 ++++- .../components/ChatInputPixelPet.scss | 397 ++++++++++++++++++ .../components/ChatInputPixelPet.tsx | 326 ++++++++++++++ .../state-machine/SessionStateMachine.ts | 12 +- .../flow_chat/utils/chatInputPetMood.test.ts | 110 +++++ .../src/flow_chat/utils/chatInputPetMood.ts | 42 ++ .../config/components/SessionConfig.tsx | 16 + .../services/AIExperienceConfigService.ts | 3 + .../src/infrastructure/config/types/index.ts | 3 + .../en-US/settings/session-config.json | 5 + .../zh-CN/settings/session-config.json | 5 + 14 files changed, 1108 insertions(+), 12 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss create mode 100644 src/web-ui/src/flow_chat/components/ChatInputPixelPet.tsx create mode 100644 src/web-ui/src/flow_chat/utils/chatInputPetMood.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/chatInputPetMood.ts diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index ea5aacb5..929e60fe 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -104,6 +104,8 @@ pub struct AIExperienceConfig { pub enable_welcome_panel_ai_analysis: bool, /// Whether to enable visual mode. pub enable_visual_mode: bool, + /// Whether to show the pixel Agent companion in the collapsed chat input. + pub enable_agent_companion: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -998,6 +1000,7 @@ impl Default for AIExperienceConfig { enable_session_title_generation: true, enable_welcome_panel_ai_analysis: false, enable_visual_mode: false, + enable_agent_companion: false, } } } diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 253ff96f..6aa6daa3 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -92,6 +92,10 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'timeout', 'confirmation', 'history', + 'companion', + 'agent', + 'partner', + '伙伴', ], }, { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index b250a71c..d23dd56f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -54,6 +54,8 @@ transform: translateY(3px); .bitfun-chat-input__box { + position: relative; + overflow: hidden; min-height: 42px; max-height: 42px; padding: 0 18px; @@ -220,6 +222,96 @@ &:hover .bitfun-chat-input__space-hint { opacity: 0.9; } + + &.bitfun-chat-input--pet-visible { + .bitfun-chat-input__input-area { + z-index: 1; + } + + .bitfun-chat-input__actions { + z-index: 2; + } + + .bitfun-chat-input__space-hint { + opacity: 0; + transition: opacity 0.36s cubic-bezier(0.4, 0, 0.2, 1); + } + + &:hover .bitfun-chat-input__space-hint { + opacity: 0.88; + } + + &.bitfun-chat-input--pet-split-send.bitfun-chat-input--processing { + .bitfun-chat-input__actions { + z-index: 5; + } + } + } + + &.bitfun-chat-input--pet-replaces-stop.bitfun-chat-input--processing { + .bitfun-chat-input__input-area { + right: 52px; + } + + &.bitfun-chat-input--pet-split-send .bitfun-chat-input__input-area { + right: 112px; + } + } + + .bitfun-chat-input__pet-wrap { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + } + + /* Full width so flex alignment is relative to the capsule (not the ~40px pet). */ + .bitfun-chat-input__pet-inner { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-left: 0; + padding-right: 0; + transition: + justify-content 0.45s cubic-bezier(0.34, 1.15, 0.64, 1), + padding 0.45s cubic-bezier(0.34, 1.15, 0.64, 1); + } + + /* Align with former stop control (~same inset as .bitfun-chat-input__actions { right: 14px }). */ + .bitfun-chat-input__pet-wrap--shift .bitfun-chat-input__pet-inner { + justify-content: flex-end; + padding-right: 8px; + } + + .bitfun-chat-input__pet-wrap--shift.bitfun-chat-input__pet-wrap--split .bitfun-chat-input__pet-inner { + padding-right: 42px; + } + + .bitfun-chat-input__pet-stop-btn { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + border-radius: 10px; + line-height: 0; + + &:hover { + filter: brightness(1.08); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-primary); + outline-offset: 2px; + } + } } &__space-hint { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 8d401509..62ee3a9f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -11,7 +11,11 @@ import { useActiveSessionState } from '../hooks/useActiveSessionState'; import { RichTextInput, type MentionState } from './RichTextInput'; import { FileMentionPicker } from './FileMentionPicker'; import { globalEventBus } from '../../infrastructure/event-bus'; -import { useSessionDerivedState, useSessionStateMachineActions } from '../hooks/useSessionStateMachine'; +import { + useSessionDerivedState, + useSessionStateMachine, + useSessionStateMachineActions, +} from '../hooks/useSessionStateMachine'; import { SessionExecutionEvent } from '../state-machine/types'; import TokenUsageIndicator from './TokenUsageIndicator'; import { ModelSelector } from './ModelSelector'; @@ -39,6 +43,9 @@ import { resolveSessionRelationship } from '../utils/sessionMetadata'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; import type { SkillInfo } from '@/infrastructure/config/types'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { deriveChatInputPetMood } from '../utils/chatInputPetMood'; +import { ChatInputPixelPet } from './ChatInputPixelPet'; import './ChatInput.scss'; const log = createLogger('ChatInput'); @@ -136,6 +143,22 @@ export const ChatInput: React.FC = ({ effectiveTargetSessionId, inputState.value.trim() ); + const sessionMachineSnapshot = useSessionStateMachine(effectiveTargetSessionId); + const petMood = useMemo( + () => deriveChatInputPetMood(sessionMachineSnapshot), + [sessionMachineSnapshot], + ); + const [agentCompanionEnabled, setAgentCompanionEnabled] = useState( + () => aiExperienceConfigService.getSettings().enable_agent_companion, + ); + useEffect(() => { + setAgentCompanionEnabled(aiExperienceConfigService.getSettings().enable_agent_companion); + return aiExperienceConfigService.addChangeListener(settings => { + setAgentCompanionEnabled(settings.enable_agent_companion); + }); + }, []); + const showCollapsedPet = + agentCompanionEnabled && !inputState.isActive && !inputState.value.trim(); const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId); const { workspace, workspacePath } = useCurrentWorkspace(); @@ -1729,10 +1752,37 @@ export const ChatInput: React.FC = ({ return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - + + const isCollapsedProcessing = !inputState.isActive && !!derivedState?.isProcessing; + const petReplacesStopChrome = agentCompanionEnabled && isCollapsedProcessing; + const petStopClickable = petReplacesStopChrome && !!derivedState?.canCancel; + const collapsedPetSplitSend = + petReplacesStopChrome && derivedState?.sendButtonMode === 'split'; + const renderActionButton = () => { if (!derivedState) return ; - + + if (petReplacesStopChrome) { + const { sendButtonMode } = derivedState; + if (sendButtonMode === 'cancel') { + return null; + } + if (sendButtonMode === 'split') { + return ( + + + + ); + } + } + const { sendButtonMode, hasQueuedInput } = derivedState; if (sendButtonMode === 'cancel') { @@ -1805,8 +1855,6 @@ export const ChatInput: React.FC = ({ ); }; - const isCollapsedProcessing = !inputState.isActive && !!derivedState?.isProcessing; - return ( <> = ({ >
@@ -1840,6 +1888,41 @@ export const ChatInput: React.FC = ({
+ {showCollapsedPet && ( +
+
+ {petStopClickable ? ( + + ) : ( + + )} +
+
+ )} {showTargetSwitcher && (
{t('chatInput.conversationTarget')} @@ -1884,7 +1967,10 @@ export const ChatInput: React.FC = ({ data-testid="chat-input-textarea" /> - {!inputState.isActive && !inputState.value.trim() && ( + {!inputState.isActive && + !inputState.value.trim() && + !agentCompanionEnabled && + !isCollapsedProcessing && ( = ({ )}
- {isCollapsedProcessing && ( + {isCollapsedProcessing && !petReplacesStopChrome && ( <> diff --git a/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss new file mode 100644 index 00000000..6f279968 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.scss @@ -0,0 +1,397 @@ +/** + * Pixel panda — process states use stronger motion + layered FX (dots / pips / sweat / speed). + */ + +.bitfun-chat-input-pixel-pet { + position: relative; + z-index: 0; + width: 44px; + height: 36px; + flex-shrink: 0; + overflow: visible; + pointer-events: none; + + &__layer { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.42s cubic-bezier(0.4, 0, 0.2, 1); + will-change: opacity; + + &[data-active='true'] { + opacity: 1; + } + } + + &__svg { + width: min(92%, 220px); + height: 36px; + max-height: 100%; + image-rendering: pixelated; + image-rendering: crisp-edges; + shape-rendering: geometricPrecision; + overflow: visible; + } +} + +.bitfun-panda__halo { + fill: #ffffff; +} + +/* Face fill: light gray so the head reads on light UI; halo + eye whites stay brighter */ +.bitfun-panda__w { + fill: #e0e4e9; +} + +.bitfun-panda__b { + fill: #141414; +} + +.bitfun-panda__eye-hi { + fill: #ffffff; +} + +.bitfun-panda__pupil { + fill: #141414; +} + +.bitfun-panda-head { + transform-origin: 16px 16px; + transform-box: fill-box; + + &--rest { + animation: bitfun-head-rest 2.2s ease-in-out infinite; + } + + &--analyzing { + animation: bitfun-head-analyze 1.1s ease-in-out infinite; + } + + &--waiting { + animation: bitfun-head-wait 0.85s ease-in-out infinite; + } + + &--working { + animation: bitfun-head-work 0.32s ease-in-out infinite; + } + + &__face--rest .bitfun-panda__eye-hi { + animation: bitfun-head-blink 3.2s ease-in-out infinite; + } + + /* Thinking: eyes look around */ + &--analyzing .bitfun-panda__pupil { + animation: bitfun-pupil-scan 0.9s ease-in-out infinite; + } + + &--analyzing .bitfun-panda-head__ears { + animation: bitfun-ear-twitch 0.55s ease-in-out infinite; + transform-origin: 16px 6px; + } + + /* Busy: ears wobble faster */ + &--working .bitfun-panda-head__ears { + animation: bitfun-ear-wiggle 0.28s ease-in-out infinite; + transform-origin: 16px 6px; + } + + &__dots .bitfun-panda-head__dot { + animation: bitfun-head-dot-bounce 0.55s ease-in-out infinite; + } + + &__dots .bitfun-panda-head__dot:nth-child(1) { + animation-delay: 0s; + } + + &__dots .bitfun-panda-head__dot:nth-child(2) { + animation-delay: 0.18s; + } + + &__dots .bitfun-panda-head__dot:nth-child(3) { + animation-delay: 0.36s; + } + + &__wait-pips .bitfun-panda-head__wait-pip { + animation: bitfun-wait-pip 0.7s ease-in-out infinite; + fill: #94a3b8; + } + + &__wait-pips .bitfun-panda-head__wait-pip:nth-child(1) { + animation-delay: 0s; + } + + &__wait-pips .bitfun-panda-head__wait-pip:nth-child(2) { + animation-delay: 0.12s; + } + + &__wait-pips .bitfun-panda-head__wait-pip:nth-child(3) { + animation-delay: 0.24s; + } + + &__sweat .bitfun-panda-head__sweat-drop { + fill: #38bdf8; + } + + &__sweat .bitfun-panda-head__sweat-drop:nth-child(1) { + animation: bitfun-sweat-a 0.55s ease-in-out infinite; + } + + &__sweat .bitfun-panda-head__sweat-drop:nth-child(2) { + animation: bitfun-sweat-b 0.55s ease-in-out 0.2s infinite; + } + + &__speed .bitfun-panda-head__speed-line { + fill: #94a3b8; + opacity: 0.85; + } + + &__speed .bitfun-panda-head__speed-line:nth-child(1), + &__speed .bitfun-panda-head__speed-line:nth-child(4) { + animation: bitfun-speed-streak 0.22s linear infinite; + } + + &__speed .bitfun-panda-head__speed-line:nth-child(2), + &__speed .bitfun-panda-head__speed-line:nth-child(5) { + animation: bitfun-speed-streak 0.22s linear 0.07s infinite; + } + + &__speed .bitfun-panda-head__speed-line:nth-child(3), + &__speed .bitfun-panda-head__speed-line:nth-child(6) { + animation: bitfun-speed-streak 0.22s linear 0.14s infinite; + } +} + +.bitfun-panda-head__dot { + fill: #7dd3fc; +} + +@keyframes bitfun-head-rest { + 0%, + 100% { + transform: translateY(0) rotate(-0.8deg) scale(1); + } + + 50% { + transform: translateY(0.6px) rotate(0.8deg) scale(1.03, 0.98); + } +} + +@keyframes bitfun-head-blink { + 0%, + 84%, + 100% { + opacity: 1; + } + + 88%, + 93% { + opacity: 0.12; + } +} + +/* Stronger “thinking” — squash + sway + bounce */ +@keyframes bitfun-head-analyze { + 0% { + transform: translateY(0) rotate(-2.5deg) scale(1, 1); + } + + 25% { + transform: translateY(-1.8px) rotate(0deg) scale(1.05, 0.96); + } + + 50% { + transform: translateY(0) rotate(2.5deg) scale(0.98, 1.04); + } + + 75% { + transform: translateY(-1px) rotate(-1deg) scale(1.04, 0.97); + } + + 100% { + transform: translateY(0) rotate(-2.5deg) scale(1, 1); + } +} + +@keyframes bitfun-pupil-scan { + 0%, + 100% { + transform: translate(0, 0); + } + + 25% { + transform: translate(1px, 0); + } + + 50% { + transform: translate(-1px, 0); + } + + 75% { + transform: translate(0, 1px); + } +} + +@keyframes bitfun-ear-twitch { + 0%, + 100% { + transform: rotate(0deg); + } + + 30% { + transform: rotate(-4deg); + } + + 60% { + transform: rotate(3deg); + } +} + +@keyframes bitfun-head-dot-bounce { + 0%, + 100% { + transform: translateY(0); + opacity: 0.55; + } + + 50% { + transform: translateY(-3px); + opacity: 1; + } +} + +/* Waiting: side-to-side + squash — reads as “blocked / pending” */ +@keyframes bitfun-head-wait { + 0%, + 100% { + transform: translateX(0) rotate(0deg) scale(1); + } + + 20% { + transform: translateX(2.5px) rotate(1.2deg) scale(1.04, 0.96); + } + + 40% { + transform: translateX(0) rotate(0deg) scale(1); + } + + 60% { + transform: translateX(-2.5px) rotate(-1.2deg) scale(0.96, 1.04); + } + + 80% { + transform: translateX(0) rotate(0deg) scale(1); + } +} + +@keyframes bitfun-wait-pip { + 0%, + 100% { + transform: translateY(0); + opacity: 0.35; + } + + 50% { + transform: translateY(-2px); + opacity: 1; + } +} + +/* Working: fast vertical pump + stretch */ +@keyframes bitfun-head-work { + 0%, + 100% { + transform: translateY(0) scale(1.02, 0.96); + } + + 35% { + transform: translateY(-2.5px) scale(0.97, 1.06); + } + + 70% { + transform: translateY(1.2px) scale(1.03, 0.95); + } +} + +@keyframes bitfun-ear-wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(5deg); + } + + 50% { + transform: rotate(-5deg); + } + + 75% { + transform: rotate(3deg); + } +} + +@keyframes bitfun-sweat-a { + 0% { + transform: translate(0, 0); + opacity: 0.9; + } + + 100% { + transform: translate(0.5px, 5px); + opacity: 0; + } +} + +@keyframes bitfun-sweat-b { + 0% { + transform: translate(0, 0); + opacity: 0.9; + } + + 100% { + transform: translate(-0.5px, 5px); + opacity: 0; + } +} + +@keyframes bitfun-speed-streak { + 0% { + opacity: 0.2; + transform: translateX(0); + } + + 40% { + opacity: 1; + transform: translateX(1px); + } + + 100% { + opacity: 0.35; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-chat-input-pixel-pet__layer { + transition-duration: 0.18s; + } + + .bitfun-panda-head--rest, + .bitfun-panda-head--analyzing, + .bitfun-panda-head--waiting, + .bitfun-panda-head--working, + .bitfun-panda-head__face--rest .bitfun-panda__eye-hi, + .bitfun-panda-head--analyzing .bitfun-panda__pupil, + .bitfun-panda-head--analyzing .bitfun-panda-head__ears, + .bitfun-panda-head--working .bitfun-panda-head__ears, + .bitfun-panda-head__dots .bitfun-panda-head__dot, + .bitfun-panda-head__wait-pips .bitfun-panda-head__wait-pip, + .bitfun-panda-head__sweat .bitfun-panda-head__sweat-drop, + .bitfun-panda-head__speed .bitfun-panda-head__speed-line { + animation: none; + } +} diff --git a/src/web-ui/src/flow_chat/components/ChatInputPixelPet.tsx b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.tsx new file mode 100644 index 00000000..2ab79da9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputPixelPet.tsx @@ -0,0 +1,326 @@ +/** + * BitFun pixel panda — 32×32, white halo + ear rims for dark UI, + * softer patches and dot eyes for silly-cute (蠢萌) read. + */ + +import React from 'react'; +import type { ChatInputPetMood } from '../utils/chatInputPetMood'; +import './ChatInputPixelPet.scss'; + +export interface ChatInputPixelPetProps { + mood: ChatInputPetMood; + className?: string; + layout?: 'center' | 'stopRight'; +} + +const MOODS: ChatInputPetMood[] = ['rest', 'analyzing', 'waiting', 'working']; + +const VIEW = 32; + +/** Cream face — slightly rounded; bottom rows taper (no “neck” block). */ +const CREAM_ROWS: [number, number, number][] = [ + [5, 10, 12], + [6, 8, 16], + [8, 6, 20], + [10, 5, 22], + [12, 4, 24], + [14, 4, 24], + [16, 4, 24], + [18, 4, 24], + [20, 4, 24], + [22, 5, 22], + [24, 6, 20], + [25, 8, 16], + [26, 10, 12], +]; + +/** + * White rim only: pad each cream row + extra top rows for ears. + * No extra full-width rows below the face (avoids “pedestal” look). + */ +function buildHaloRows(pad: number): [number, number, number][] { + const out: [number, number, number][] = []; + const first = CREAM_ROWS[0]; + out.push([first[0] - 2, first[1] - pad, first[2] + 2 * pad]); + out.push([first[0] - 1, first[1] - pad, first[2] + 2 * pad]); + for (const r of CREAM_ROWS) { + out.push([r[0], r[1] - pad, r[2] + 2 * pad]); + } + return out; +} + +const HALO_ROWS = buildHaloRows(3); + +/** Left / right ear — black pixels (drawn after white ear-rim). */ +const EAR_LEFT: [number, number, number, number][] = [ + [2, 5, 4, 2], + [3, 4, 6, 2], + [4, 4, 6, 2], +]; +const EAR_RIGHT: [number, number, number, number][] = [ + [2, 23, 4, 2], + [3, 22, 6, 2], + [4, 22, 6, 2], +]; + +/** White under ears so black ears read on dark backgrounds. */ +const EAR_RIM_LEFT: [number, number, number, number][] = [ + [1, 4, 6, 4], + [2, 3, 8, 4], + [3, 3, 8, 4], + [4, 3, 8, 4], +]; +const EAR_RIM_RIGHT: [number, number, number, number][] = [ + [1, 22, 6, 4], + [2, 21, 8, 4], + [3, 21, 8, 4], + [4, 21, 8, 4], +]; + +/** Irregular oval-ish patches (less “gas mask” than big rectangles). */ +const PATCH_LEFT: [number, number, number, number][] = [ + [9, 5, 6, 2], + [10, 4, 8, 2], + [11, 3, 10, 6], + [12, 3, 10, 6], + [13, 4, 8, 2], + [14, 5, 6, 2], +]; +const PATCH_RIGHT: [number, number, number, number][] = [ + [9, 19, 6, 2], + [10, 18, 8, 2], + [11, 17, 10, 6], + [12, 17, 10, 6], + [13, 18, 8, 2], + [14, 19, 6, 2], +]; + +function Px({ + x, + y, + w = 1, + h = 1, + className, +}: { + x: number; + y: number; + w?: number; + h?: number; + className?: string; +}) { + return ; +} + +/** Slight overlap removes 1px scanline gaps when scaled. */ +function PxRow({ x, y, w, className }: { x: number; y: number; w: number; className?: string }) { + return ; +} + +function HeadHalo() { + const cls = 'bitfun-panda__halo'; + return ( + + {HALO_ROWS.map(([y, x0, lw], i) => ( + + ))} + + ); +} + +function HeadDisk() { + const cls = 'bitfun-panda__w'; + return ( + + {CREAM_ROWS.map(([y, x0, lw], i) => ( + + ))} + + ); +} + +function EarRims() { + const cls = 'bitfun-panda__halo'; + return ( + + {EAR_RIM_LEFT.map(([y, x, w, h], i) => ( + + ))} + {EAR_RIM_RIGHT.map(([y, x, w, h], i) => ( + + ))} + + ); +} + +function Ears() { + const b = 'bitfun-panda__b'; + return ( + + {EAR_LEFT.map(([y, x, w, h], i) => ( + + ))} + {EAR_RIGHT.map(([y, x, w, h], i) => ( + + ))} + + ); +} + +function EyePatches() { + const b = 'bitfun-panda__b'; + return ( + + {PATCH_LEFT.map(([y, x, w, h], i) => ( + + ))} + {PATCH_RIGHT.map(([y, x, w, h], i) => ( + + ))} + + ); +} + +/** Small inverted-T nose — cute button, not wide bar. */ +function Nose() { + const b = 'bitfun-panda__b'; + return ( + + + + + ); +} + +/** Open: chunky whites + 1px pupils (offset slightly — silly). */ +function EyesOpen() { + const hi = 'bitfun-panda__eye-hi'; + const pu = 'bitfun-panda__pupil'; + return ( + + + + + + + ); +} + +/** Rest: soft “sleep” arcs — two short curves, not one stern bar. */ +function FaceRest() { + const hi = 'bitfun-panda__eye-hi'; + return ( + + + + + + + + ); +} + +function FaceAnalyzing() { + return ( + + + + + + + + + + ); +} + +function FaceWaiting() { + return ( + + + + + + + + + + ); +} + +function FaceWorking() { + return ( + + + + + + + + + + + + + + + + + ); +} + +function Face({ mood }: { mood: ChatInputPetMood }) { + switch (mood) { + case 'rest': + return ; + case 'analyzing': + return ; + case 'waiting': + return ; + default: + return ; + } +} + +function MoodSvg({ mood }: { mood: ChatInputPetMood }) { + const rootClass = `bitfun-panda-head bitfun-panda-head--${mood}`; + + return ( + + + + + + + + + + + ); +} + +export const ChatInputPixelPet: React.FC = ({ + mood, + className = '', + layout = 'center', +}) => { + const layoutMod = + layout === 'stopRight' ? ' bitfun-chat-input-pixel-pet--layout-stop-right' : ''; + return ( +
+ {MOODS.map(m => ( +
+ +
+ ))} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts index 0dbbbce2..1fda7c93 100644 --- a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts +++ b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts @@ -132,7 +132,9 @@ export class SessionStateMachineImpl { this.context.version += 1; this.context.lastUpdateTime = Date.now(); + const processingPhaseBefore = this.context.processingPhase; this.updateContext(event, payload); + const processingPhaseAfter = this.context.processingPhase; if (this.transitionHistory.length > SessionStateMachineImpl.MAX_HISTORY_LENGTH * 2) { this.transitionHistory = this.transitionHistory.slice(-SessionStateMachineImpl.MAX_HISTORY_LENGTH); @@ -149,11 +151,12 @@ export class SessionStateMachineImpl { await this.runSideEffects(event, payload); - // TEXT_CHUNK_RECEIVED is a self-loop (PROCESSING→PROCESSING / FINISHING→FINISHING) - // that only increments stats.textCharsGenerated. Skip the expensive snapshot clone - // and listener broadcast to avoid per-chunk overhead during streaming. + // TEXT_CHUNK_RECEIVED is high-frequency: skip notify when phase is unchanged (STREAMING→STREAMING). + // When phase changes (e.g. THINKING→STREAMING on first chunk), subscribers must update (pet, progress). if (event !== SessionExecutionEvent.TEXT_CHUNK_RECEIVED) { this.notifyListeners(); + } else if (processingPhaseBefore !== processingPhaseAfter) { + this.notifyListeners(); } return true; @@ -168,7 +171,8 @@ export class SessionStateMachineImpl { const shouldUpdatePhase = this.currentState === SessionExecutionState.PROCESSING || event === SessionExecutionEvent.BACKEND_STREAM_COMPLETED; - if (shouldUpdatePhase && newPhase !== null && newPhase !== undefined) { + // Allow null (e.g. TOOL_COMPLETED clears phase so UI leaves "tool calling" before the next round). + if (shouldUpdatePhase && newPhase !== undefined) { this.context.processingPhase = newPhase; } } else { diff --git a/src/web-ui/src/flow_chat/utils/chatInputPetMood.test.ts b/src/web-ui/src/flow_chat/utils/chatInputPetMood.test.ts new file mode 100644 index 00000000..6e7350cd --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/chatInputPetMood.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { deriveChatInputPetMood } from './chatInputPetMood'; +import { + SessionExecutionState, + ProcessingPhase, + type SessionStateMachine, +} from '../state-machine/types'; + +function makeSnapshot( + state: SessionExecutionState, + phase: ProcessingPhase | null, +): SessionStateMachine { + return { + sessionId: 's1', + currentState: state, + context: { + taskId: null, + currentDialogTurnId: null, + currentModelRoundId: null, + pendingToolConfirmations: new Set(), + errorMessage: null, + queuedInput: null, + processingPhase: phase, + planner: null, + stats: { + startTime: null, + textCharsGenerated: 0, + toolsExecuted: 0, + }, + version: 1, + lastUpdateTime: 0, + backendSyncedAt: null, + errorRecovery: { + errorCount: 0, + lastErrorTime: null, + errorType: null, + recoverable: false, + }, + }, + transitionHistory: [], + }; +} + +describe('deriveChatInputPetMood', () => { + it('returns rest when snapshot is null', () => { + expect(deriveChatInputPetMood(null)).toBe('rest'); + }); + + it('returns rest when idle', () => { + expect(deriveChatInputPetMood(makeSnapshot(SessionExecutionState.IDLE, null))).toBe('rest'); + }); + + it('maps only THINKING to analyzing', () => { + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.THINKING), + ), + ).toBe('analyzing'); + }); + + it('maps starting and compacting to working', () => { + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.STARTING), + ), + ).toBe('working'); + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.COMPACTING), + ), + ).toBe('working'); + }); + + it('maps tool phases to waiting', () => { + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.TOOL_CALLING), + ), + ).toBe('waiting'); + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.TOOL_CONFIRMING), + ), + ).toBe('waiting'); + }); + + it('maps streaming, finalizing, and null phase to working', () => { + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.STREAMING), + ), + ).toBe('working'); + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.PROCESSING, ProcessingPhase.FINALIZING), + ), + ).toBe('working'); + expect( + deriveChatInputPetMood(makeSnapshot(SessionExecutionState.PROCESSING, null)), + ).toBe('working'); + }); + + it('treats finishing state like processing for mood', () => { + expect( + deriveChatInputPetMood( + makeSnapshot(SessionExecutionState.FINISHING, ProcessingPhase.FINALIZING), + ), + ).toBe('working'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/chatInputPetMood.ts b/src/web-ui/src/flow_chat/utils/chatInputPetMood.ts new file mode 100644 index 00000000..2426ed6a --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/chatInputPetMood.ts @@ -0,0 +1,42 @@ +/** + * Maps session state machine snapshot to chat input pixel pet mood. + * + * Design: + * - rest: task not running (idle / before start / after completion) + * - analyzing: model thinking only (THINKING) + * - waiting: tool invocation / confirmation + * - working: all other in-flight phases (starting, compacting, streaming, finalizing, or phase cleared between steps) + */ + +import { + SessionExecutionState, + ProcessingPhase, + type SessionStateMachine, +} from '../state-machine/types'; + +export type ChatInputPetMood = 'rest' | 'analyzing' | 'waiting' | 'working'; + +export function deriveChatInputPetMood(snapshot: SessionStateMachine | null): ChatInputPetMood { + if (!snapshot) return 'rest'; + + const { currentState, context } = snapshot; + const phase = context.processingPhase; + + const isProcessing = + currentState === SessionExecutionState.PROCESSING || + currentState === SessionExecutionState.FINISHING; + + if (!isProcessing) { + return 'rest'; + } + + if (phase === ProcessingPhase.THINKING) { + return 'analyzing'; + } + + if (phase === ProcessingPhase.TOOL_CALLING || phase === ProcessingPhase.TOOL_CONFIRMING) { + return 'waiting'; + } + + return 'working'; +} diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 64e499d9..1f1010ba 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -455,6 +455,22 @@ const SessionConfig: React.FC = () => { + {/* ── Agent companion (collapsed input) ─────────────────── */} + + +
+ updateSetting('enable_agent_companion', e.target.checked)} + size="small" + /> +
+
+
+ {/* ── Tool execution behavior ────────────────────────────── */}