diff --git a/app/config/settings.json b/app/config/settings.json index 22101bbac..65e95adaa 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -25,7 +25,7 @@ "openai": "", "anthropic": "", "google": "", - "byteplus": "62a75ab1-0f00-4d4e-8873-23551e624375", + "byteplus": "", "openrouter": "" }, "endpoints": { diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json index 94b51372b..7ef85425e 100644 --- a/app/ui_layer/browser/frontend/package-lock.json +++ b/app/ui_layer/browser/frontend/package-lock.json @@ -11,6 +11,7 @@ "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", + "prism-react-renderer": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", @@ -1486,6 +1487,12 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2012,6 +2019,15 @@ "url": "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/sponsors/wooorm" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4243,6 +4259,19 @@ "node": ">= 0.8.0" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json index 214bc4d0a..33d5584b1 100644 --- a/app/ui_layer/browser/frontend/package.json +++ b/app/ui_layer/browser/frontend/package.json @@ -13,6 +13,7 @@ "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", + "prism-react-renderer": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", diff --git a/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_day.jpg b/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_day.jpg new file mode 100644 index 000000000..a8cc2c6b5 Binary files /dev/null and b/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_day.jpg differ diff --git a/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_night.jpg b/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_night.jpg new file mode 100644 index 000000000..cec60bf20 Binary files /dev/null and b/app/ui_layer/browser/frontend/public/mascot-backgrounds/background_1_night.jpg differ diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css index d9c2d34ae..035444c89 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css @@ -458,6 +458,32 @@ font-style: italic; } +/* Active-state placeholder shown at the end of a task's child action list + (Thinking… / Waiting for reply… / Paused…). Non-interactive except for + an always-visible reply button when the task is waiting. */ +.placeholderItem { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + color: var(--text-muted); + font-style: italic; +} + +/* Reply button on the placeholder — same look as .taskReplyBtn but always + visible (no hover-revealed opacity). */ +.placeholderReplyBtn { + flex-shrink: 0; + color: var(--text-muted); + transition: color var(--transition-fast), background var(--transition-fast); +} + +.placeholderReplyBtn:hover { + color: var(--color-primary); + background: var(--color-primary-light); +} + /* Hidden file input */ .hiddenFileInput { display: none; diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 149765721..a20cacc84 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -3,10 +3,12 @@ import { X, Loader2, Reply } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { IconButton, StatusIndicator } from '../../components/ui' import { Chat } from '../../components/Chat' +import { MascotDisplay } from '@mascot' +import { getActivePlaceholder } from '../../utils/taskPlaceholder' import styles from './ChatPage.module.css' // Panel width limits -const DEFAULT_PANEL_WIDTH = 380 +const DEFAULT_PANEL_WIDTH = 460 const MIN_PANEL_WIDTH = 200 const MAX_PANEL_WIDTH = 800 @@ -92,6 +94,7 @@ export function ChatPage() { {/* Task/Action Panel */}
+

Tasks & Actions

@@ -101,67 +104,94 @@ export function ChatPage() {

No active tasks

) : ( - tasks.map(task => ( -
-
setSelectedTaskId( - selectedTaskId === task.id ? null : task.id - )} - > - - {task.name} - {(task.status === 'running' || task.status === 'waiting') && ( - <> - {!tasksAwaitingOption.has(task.id) && ( + tasks.map(task => { + const isExpanded = selectedTaskId === task.id + const taskActions = isExpanded ? getActionsForTask(task.id) : [] + const listPlaceholder = isExpanded + ? getActivePlaceholder(task.status, taskActions) + : null + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + + return ( +
+
setSelectedTaskId(isExpanded ? null : task.id)} + > + + {task.name} + {(task.status === 'running' || task.status === 'waiting') && ( + <> + {!tasksAwaitingOption.has(task.id) && ( + { + e.stopPropagation() + handleTaskReply(task.id, task.name) + }} + title="Reply to Task" + icon={} + /> + )} { e.stopPropagation() - handleTaskReply(task.id, task.name) + cancelTask(task.id) }} - title="Reply to Task" - icon={} + disabled={cancellingTaskId === task.id} + title="Cancel Task" + icon={ + cancellingTaskId === task.id ? ( + + ) : ( + + ) + } /> + + )} +
+ {isExpanded && ( +
+ {taskActions.map(action => ( +
+ + {action.name} +
+ ))} + {listPlaceholder && ( +
+ + {listPlaceholder.label} + {showListReply && ( + { + e.stopPropagation() + handleTaskReply(task.id, task.name) + }} + title="Reply to Task" + icon={} + /> + )} +
)} - { - e.stopPropagation() - cancelTask(task.id) - }} - disabled={cancellingTaskId === task.id} - title="Cancel Task" - icon={ - cancellingTaskId === task.id ? ( - - ) : ( - - ) - } - /> - + {taskActions.length === 0 && !listPlaceholder && ( +
No actions yet
+ )} +
)}
- {selectedTaskId === task.id && ( -
- {getActionsForTask(task.id).map(action => ( -
- - {action.name} -
- ))} - {getActionsForTask(task.id).length === 0 && ( -
No actions yet
- )} -
- )} -
- )) + ) + }) )}
diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/CraftBotPet.tsx b/app/ui_layer/browser/frontend/src/pages/LivingUI/CraftBotPet.tsx deleted file mode 100644 index 2f6103335..000000000 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/CraftBotPet.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import styles from './LivingUIPage.module.css' - -export type PetState = 'creating' | 'launching' | 'stopped' - -interface Props { - state: PetState - progress?: number - indeterminate?: boolean - completedCount?: number - size?: number -} - -// Eye path d-values lifted from the original logo (paths 3 and 2 in the SVG). -// Keeping them in their own wrappers lets us apply CSS transforms (blink/close) -// without fighting the SVG-attribute transforms on the paths themselves. -const LEFT_EYE_D = - 'M0 0 C2.92937654 1.81310142 5.43647949 3.87295899 7 7 C7.12311126 9.56761586 7.18836415 12.10706019 7.203125 14.67578125 C7.20882507 15.43795456 7.21452515 16.20012787 7.22039795 16.98539734 C7.22985081 18.60019542 7.23638307 20.21501282 7.24023438 21.82983398 C7.24992133 24.29250092 7.28093303 26.75432656 7.3125 29.21679688 C7.3190321 30.78645338 7.32428176 32.35611586 7.328125 33.92578125 C7.3404718 34.65928299 7.3528186 35.39278473 7.36553955 36.14851379 C7.34154924 41.69538739 6.08230904 44.91769096 2.1875 48.8125 C-0.8527265 50.46290867 -2.57949832 50.54728027 -6 50 C-8.9228334 48.16899068 -11.43323854 46.13352292 -13 43 C-13.28424778 37.93535225 -13.31377183 32.86972252 -13.35009766 27.79785156 C-13.36663059 26.1063051 -13.39388581 24.41482773 -13.43212891 22.72363281 C-13.48710365 20.27893001 -13.50880304 17.835864 -13.5234375 15.390625 C-13.54610687 14.64041077 -13.56877625 13.89019653 -13.59213257 13.11724854 C-13.57625216 9.13973933 -13.06467198 6.59267156 -10.59228516 3.53808594 C-6.97219204 -0.00631866 -5.04757551 -0.58241256 0 0 Z' - -const RIGHT_EYE_D = - 'M0 0 C4.33669495 0.36139125 5.72030822 1.28280822 8.75 4.3125 C11.19715759 7.98323639 11.07300278 10.80084797 11.0859375 15.05078125 C11.0925943 15.80916794 11.0992511 16.56755463 11.10610962 17.34892273 C11.11623038 18.95353778 11.12092682 20.55819486 11.12060547 22.1628418 C11.12496613 24.60602159 11.16125324 27.04737571 11.19921875 29.49023438 C11.20508977 31.05207718 11.20905726 32.61392854 11.2109375 34.17578125 C11.22530853 34.90098465 11.23967957 35.62618805 11.25448608 36.37336731 C11.20886626 41.49335244 9.99115623 44.38403683 6.75 48.3125 C3.0126144 50.8040904 1.17668417 50.77043285 -3.25 50.3125 C-6.00329021 48.51687595 -7.77627461 47.25995078 -9.25 44.3125 C-9.53474008 38.94460889 -9.56375869 33.57565738 -9.60009766 28.20092773 C-9.61665091 26.40554842 -9.6439329 24.61023484 -9.68212891 22.81518555 C-9.73696923 20.22409959 -9.75878148 17.63455732 -9.7734375 15.04296875 C-9.79610687 14.24384567 -9.81877625 13.4447226 -9.84213257 12.62138367 C-9.82575533 8.28890941 -9.65327311 6.79951266 -6.77978516 3.3293457 C-5.94495605 2.66378662 -5.11012695 1.99822754 -4.25 1.3125 C-3.25 0.3125 -3.25 0.3125 0 0 Z' - -export function CraftBotPet({ - state, - progress = 0, - indeterminate = false, - completedCount = 0, - size = 140, -}: Props) { - const [wiggling, setWiggling] = useState(false) - const prevCompleted = useRef(completedCount) - - useEffect(() => { - if (completedCount > prevCompleted.current) { - setWiggling(true) - const t = setTimeout(() => setWiggling(false), 600) - prevCompleted.current = completedCount - return () => clearTimeout(t) - } - prevCompleted.current = completedCount - }, [completedCount]) - - const sleeping = state === 'stopped' - const showBlush = state === 'creating' && progress > 60 - const antennaActive = state !== 'stopped' - const breatheClass = sleeping ? styles.petSleepBreathe : styles.petBreathe - const eyeClass = sleeping ? styles.petEyeClosed : styles.petEye - - return ( - - ) -} diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx index 33a233423..59ed11244 100644 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx +++ b/app/ui_layer/browser/frontend/src/pages/LivingUI/CreationProgress.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { Lightbulb } from 'lucide-react' import type { LivingUITodo } from '../../types' import { useRotatingHint } from '../../hooks' -import { CraftBotPet } from './CraftBotPet' +import { CraftBotMascot } from '@mascot' import styles from './LivingUIPage.module.css' interface Props { @@ -76,10 +76,9 @@ export function CreationProgress({ projectName, todos }: Props) { return (
-

Creating {projectName}

diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.module.css b/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.module.css index 219249176..26941ffb3 100644 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.module.css @@ -156,121 +156,6 @@ color: var(--text-muted); } -/* CraftBot Pet */ -.petWrapper { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: var(--space-2); -} - -.petFloat { - display: inline-flex; - transform-origin: 50% 85%; -} - -.petBreathe { - transform-origin: 50% 60%; -} - -.petSleepBreathe { - transform-origin: 50% 60%; -} - -.petEye { - transform-origin: center; - transform-box: fill-box; - animation: petBlink 4.5s infinite; -} - -.petEyeLeft { - animation-delay: 0ms; -} - -.petEyeRight { - animation-delay: 120ms; -} - -.petEyeClosed { - transform-origin: center; - transform-box: fill-box; - transform: scaleY(0.08); -} - -.petAntennaPulse { - transform-origin: center; - transform-box: fill-box; -} - -.petBlushPulse { -} - -.petWiggle { -} - -/* Body silhouette is near-white — in light mode it blends into the bg, - so stroke a thin outline. Dark mode needs no outline. */ -.petBody { - stroke: none; -} - -[data-theme="light"] .petBody { - stroke: var(--text-muted, var(--border-secondary)); - stroke-width: 3; - paint-order: stroke fill; -} - -.petSleepZ { - transform-origin: center; - transform-box: fill-box; - animation: petSleepZ 2.4s ease-in-out infinite; - opacity: 0; -} - -.petSleepZDelayed { - animation-delay: 1.2s; -} - -@keyframes petFloat { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-6px); } -} - -@keyframes petBreathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.015); } -} - -@keyframes petBlink { - 0%, 92%, 100% { transform: scaleY(1); } - 95%, 97% { transform: scaleY(0.05); } -} - -@keyframes petAntennaPulse { - 0%, 100% { opacity: 0.45; transform: scale(1); } - 50% { opacity: 1; transform: scale(1.12); } -} - -@keyframes petBlushPulse { - 0%, 100% { opacity: 0.45; } - 50% { opacity: 0.9; } -} - -@keyframes petWiggle { - 0% { transform: translateY(0) rotate(0deg); } - 20% { transform: translateY(-4px) rotate(-6deg); } - 45% { transform: translateY(-2px) rotate(6deg); } - 70% { transform: translateY(-1px) rotate(-3deg); } - 100% { transform: translateY(0) rotate(0deg); } -} - -@keyframes petSleepZ { - 0% { opacity: 0; transform: translateY(0); } - 30% { opacity: 1; } - 100% { opacity: 0; transform: translateY(-14px); } -} - - /* Creation Progress */ .creationProgress { display: flex; diff --git a/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.tsx b/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.tsx index 2871a7552..4e5eefa8b 100644 --- a/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/LivingUI/LivingUIPage.tsx @@ -11,7 +11,7 @@ import { Maximize2, Minimize2, } from 'lucide-react' -import { CraftBotPet } from './CraftBotPet' +import { CraftBotMascot } from '@mascot' import { useWebSocket } from '../../contexts/WebSocketContext' import { useFullscreen } from '../../contexts/FullscreenContext' import { Button } from '../../components/ui/Button' @@ -267,7 +267,7 @@ export function LivingUIPage() { /> ) : project.status === 'launching' ? (
- +

Launching Living UI...

Installing dependencies, running tests, starting servers

@@ -282,7 +282,7 @@ export function LivingUIPage() {
) : (
- +

Living UI is not running

+
+ + {expanded && ( +
+
+
Type
+
{item.itemType}
+
ID
+
{item.id}
+
Started
+
{formatTimestamp(item.createdAt)}
+
Duration
+
{formatDuration(item.duration)}
+
+
+ )} + + + + ) +} + +// Trailing placeholder rendered at the end of the transcript while the task +// is still active (Thinking / Waiting for reply / Paused). Shows an +// always-visible reply icon-button when the task is awaiting a user reply. +interface TranscriptPlaceholderProps { + placeholder: ActivePlaceholder + showReply: boolean + onReply: () => void +} + +function TranscriptPlaceholder({ placeholder, showReply, onReply }: TranscriptPlaceholderProps) { + return ( +
+
+
+ +
+
+
+ {placeholder.label} + {showReply && ( + } + /> + )} +
+
+ ) +} + +// Scrollable body of the detail panel: chronological list of reasoning + +// action items, with a trailing active-state placeholder while the task is +// still running/waiting/paused. +interface TaskTranscriptProps { + task: ActionItem + items: ActionItem[] + expandedDetailIds: Set + onToggleDetail: (id: string) => void + tasksAwaitingOption: Set + onTaskReply: (task: ActionItem) => void +} + +function TaskTranscript({ + task, + items, + expandedDetailIds, + onToggleDetail, + tasksAwaitingOption, + onTaskReply, +}: TaskTranscriptProps) { + const placeholder = getActivePlaceholder(task.status, items) + + if (items.length === 0 && !placeholder) { + return ( +
+ No actions or reasoning recorded. +
+ ) + } + + const showReply = + placeholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + + return ( +
+ {items.map(item => + item.itemType === 'reasoning' ? ( + + ) : ( + onToggleDetail(item.id)} + /> ) - })} - + )} + {placeholder && ( + onTaskReply(task)} + /> + )} +
) } @@ -387,7 +550,14 @@ export function TasksPage() { const internalSkillNames = useMemo(() => new Set(skillMeta.internalSkillNames), [skillMeta.internalSkillNames]) const reservedSkillNames = useMemo(() => new Set(skillMeta.reservedSkillNames), [skillMeta.reservedSkillNames]) const navigate = useNavigate() - const [selectedItem, setSelectedItem] = useState(null) + + // Body navigation state: + // selectedTaskId — which task's transcript is shown in the body + // scrollTargetId — child action/reasoning to scroll to; null = scroll to latest + // expandedDetailIds — action ids whose "More detail" panel is open + const [selectedTaskId, setSelectedTaskId] = useState(null) + const [scrollTargetId, setScrollTargetId] = useState(null) + const [expandedDetailIds, setExpandedDetailIds] = useState>(new Set()) const [mobileShowDetail, setMobileShowDetail] = useState(false) const skillCreator = useSkillCreator() @@ -395,8 +565,27 @@ export function TasksPage() { const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) const [isResizing, setIsResizing] = useState(false) const containerRef = useRef(null) + const bodyRef = useRef(null) + // Tracks whether the user is currently following along near the bottom of + // the transcript. Updated by the scroll listener; consulted by auto-follow. + // Same pattern as the chat panel's wasNearBottomRef. + const wasNearBottomRef = useRef(true) + // A counter we bump every second to re-render live durations for running items. + const [, forceTick] = useState(0) + + const tasks = useMemo(() => actions.filter(a => a.itemType === 'task'), [actions]) + + const selectedTask = useMemo( + () => tasks.find(t => t.id === selectedTaskId) ?? null, + [tasks, selectedTaskId], + ) - const tasks = actions.filter(a => a.itemType === 'task') + const transcriptItems = useMemo(() => { + if (!selectedTaskId) return [] + return actions + .filter(a => a.parentId === selectedTaskId && (a.itemType === 'action' || a.itemType === 'reasoning')) + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + }, [actions, selectedTaskId]) // Tasks whose latest UX gate is an unanswered option prompt — the user // must click an option, so suppress the reply affordance for these. @@ -421,34 +610,112 @@ export function TasksPage() { navigate('/chat') }, [setReplyTarget, navigate]) - // Get all items (actions + reasoning) for a task - const getItemsForTask = (taskId: string) => - actions.filter(a => (a.itemType === 'action' || a.itemType === 'reasoning') && a.parentId === taskId) - - // Get only actual actions (not reasoning) for count - const getActionCountForTask = (taskId: string) => - actions.filter(a => a.itemType === 'action' && a.parentId === taskId).length - - const formatDuration = (ms?: number) => { - if (!ms) return '-' - if (ms < 1000) return `${ms}ms` - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` - return `${(ms / 60000).toFixed(1)}m` - } + // Items shown inline beneath a task in the left list (actions + reasoning) + const getItemsForTask = useCallback( + (taskId: string) => + actions.filter(a => (a.itemType === 'action' || a.itemType === 'reasoning') && a.parentId === taskId), + [actions], + ) - const formatTimestamp = (ts?: number) => { - if (!ts) return '-' - return new Date(ts).toLocaleString() - } + // Action count per task (excludes reasoning) — used for the badge + const getActionCountForTask = useCallback( + (taskId: string) => + actions.filter(a => a.itemType === 'action' && a.parentId === taskId).length, + [actions], + ) - // Handle selecting an item (with mobile detail view) - const handleSelectItem = useCallback((item: ActionItem | null) => { - setSelectedItem(item) - if (item) { - setMobileShowDetail(true) + // Clicking a task or action in the left list drives the body's view. + // Tasks → show that task, scroll to latest progress. + // Actions/reasoning → show their parent task, scroll to the clicked item. + const handleSelectFromList = useCallback((item: ActionItem) => { + if (item.itemType === 'task') { + if (selectedTaskId === item.id) { + // Toggle off when clicking the already-selected task + setSelectedTaskId(null) + setScrollTargetId(null) + } else { + setSelectedTaskId(item.id) + setScrollTargetId(null) + } + } else { + setSelectedTaskId(item.parentId ?? null) + setScrollTargetId(item.id) } + setMobileShowDetail(true) + }, [selectedTaskId]) + + const toggleDetailExpansion = useCallback((id: string) => { + setExpandedDetailIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) }, []) + // Scroll-position tracker (mirrors Chat.tsx). Maintains wasNearBottomRef so + // auto-follow can tell at a glance whether the user is still "following + // along" at the bottom or has scrolled up to inspect previous activity. + useEffect(() => { + const body = bodyRef.current + if (!body) return + const handleScroll = () => { + const distFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight + wasNearBottomRef.current = distFromBottom < 100 + } + body.addEventListener('scroll', handleScroll) + return () => body.removeEventListener('scroll', handleScroll) + }, [selectedTaskId]) + + // Scroll to the target item (or to latest) when selection changes. + useEffect(() => { + if (!selectedTaskId) return + const body = bodyRef.current + if (!body) return + // Wait a tick so the new transcript has rendered before measuring. + const timer = setTimeout(() => { + if (scrollTargetId) { + const el = document.getElementById(`transcript-item-${scrollTargetId}`) + if (el && bodyRef.current) { + const bodyEl = bodyRef.current + const bodyRect = bodyEl.getBoundingClientRect() + const elRect = el.getBoundingClientRect() + const offset = elRect.top - bodyRect.top + bodyEl.scrollTop - 16 + bodyEl.scrollTo({ top: offset, behavior: 'smooth' }) + } + // Jumping mid-transcript counts as "not following the tail." + wasNearBottomRef.current = false + } else { + body.scrollTo({ top: body.scrollHeight, behavior: 'smooth' }) + wasNearBottomRef.current = true + } + }, 60) + return () => clearTimeout(timer) + }, [selectedTaskId, scrollTargetId]) + + // Auto-follow: when new transcript items arrive on an active task, stick to + // the bottom only if the user was near the bottom. If they scrolled up to + // look at previous activity, leave them where they are. + useEffect(() => { + if (!selectedTask) return + if (selectedTask.status !== 'running' && selectedTask.status !== 'waiting') return + if (!wasNearBottomRef.current) return + const body = bodyRef.current + if (!body) return + body.scrollTo({ top: body.scrollHeight }) + }, [transcriptItems, selectedTask]) + + // Live ticker for running item durations. 100ms keeps the "X.Xs" decimal + // updating smoothly instead of jumping a second at a time. + useEffect(() => { + const hasRunning = + (selectedTask?.status === 'running' || selectedTask?.status === 'waiting') || + transcriptItems.some(i => i.status === 'running' || i.status === 'waiting') + if (!hasRunning) return + const interval = setInterval(() => forceTick(t => t + 1), 100) + return () => clearInterval(interval) + }, [transcriptItems, selectedTask]) + // Handle back button on mobile const handleMobileBack = useCallback(() => { setMobileShowDetail(false) @@ -486,6 +753,10 @@ export function TasksPage() { } }, [isResizing]) + const canCreateSkill = + selectedTask?.status === 'completed' && + !isInternalWorkflowTask(selectedTask, internalWorkflowIds, internalSkillNames) + return (
{/* Task List - Left Side (resizable) */} @@ -504,25 +775,22 @@ export function TasksPage() { tasks.map(task => { const taskItems = getItemsForTask(task.id) const actionCount = getActionCountForTask(task.id) - const isExpanded = selectedItem?.id === task.id || - taskItems.some(a => a.id === selectedItem?.id) + const isCurrentTask = selectedTaskId === task.id + const listPlaceholder = isCurrentTask + ? getActivePlaceholder(task.status, taskItems) + : null + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) return (
- {isExpanded && ( + {isCurrentTask && (
- {taskItems.length > 0 ? ( - taskItems.map(action => ( - - )) - ) : ( + {taskItems.map(action => ( + + ))} + {listPlaceholder && ( +
+ + {listPlaceholder.label} + {showListReply && ( + { + e.stopPropagation() + handleTaskReply(task) + }} + title="Reply to Task" + icon={} + /> + )} +
+ )} + {taskItems.length === 0 && !listPlaceholder && (
No actions yet
)}
@@ -590,15 +870,13 @@ export function TasksPage() {
{/* Resize Handle */} -
+
{/* Detail Panel - Right Side */}
- {selectedItem ? ( + {selectedTask ? ( <> + {/* Header — task name + status + task-level actions */}
- -

{selectedItem.name}

+ +

{selectedTask.name}

+
+
+ {(selectedTask.status === 'running' || selectedTask.status === 'waiting') ? ( + + ) : canCreateSkill ? ( + + ) : (selectedTask.status === 'error' || selectedTask.status === 'cancelled') && ( + Aborted + )}
- {selectedItem.itemType === 'task' && ( -
- {(selectedItem.status === 'running' || selectedItem.status === 'waiting') ? ( - - ) : selectedItem.status === 'completed' && !isInternalWorkflowTask(selectedItem, internalWorkflowIds, internalSkillNames) ? ( - - ) : (selectedItem.status === 'error' || selectedItem.status === 'cancelled') && ( - Aborted - )} -
- )}
-
-
-

Details

+ {/* Body — scrollable transcript */} +
+ {/* Task details card sits at the top of the body */} +
+

Task Details

-
Type
-
{selectedItem.itemType}
-
ID
-
{selectedItem.id}
- {selectedItem.parentId && ( - <> -
Parent Task
-
{selectedItem.parentId}
- - )}
Started
-
{formatTimestamp(selectedItem.createdAt)}
+
{formatTimestamp(selectedTask.createdAt)}
Duration
-
{formatDuration(selectedItem.duration)}
- {selectedItem.itemType === 'task' && selectedItem.inputTokens != null && ( +
{formatDuration(getElapsedMs(selectedTask))}
+ {selectedTask.inputTokens != null && ( <>
Input Tokens
-
{selectedItem.inputTokens.toLocaleString()}
+
{selectedTask.inputTokens.toLocaleString()}
)} - {selectedItem.itemType === 'task' && selectedItem.outputTokens != null && ( + {selectedTask.outputTokens != null && ( <>
Output Tokens
-
{selectedItem.outputTokens.toLocaleString()}
+
{selectedTask.outputTokens.toLocaleString()}
)} - {selectedItem.itemType === 'task' && selectedItem.cacheTokens != null && ( + {selectedTask.cacheTokens != null && ( <>
Cache Tokens
-
{selectedItem.cacheTokens.toLocaleString()}
+
{selectedTask.cacheTokens.toLocaleString()}
)}
- {selectedItem.itemType === 'reasoning' ? ( - selectedItem.output && ( -
-

Content

-
- {selectedItem.output} -
-
- ) - ) : ( - <> - {selectedItem.input && (() => { - const normalizedName = (selectedItem.name || '') - .toLowerCase() - .replace(/[\s-]+/g, '_') - const isTodoAction = - selectedItem.itemType === 'action' && - normalizedName === 'task_update_todos' - const todos = isTodoAction ? extractTodos(selectedItem.input) : null - return ( -
-

{todos ? 'Todos' : 'Input'}

- {todos - ? - : } -
- ) - })()} - - {selectedItem.output && ( -
-

Output

- -
- )} - - )} - - {selectedItem.error && ( -
-

Error

+ + + {selectedTask.error && ( +
+

Task Error

-                    {selectedItem.error}
+                    {selectedTask.error}
                   
)} @@ -729,7 +968,7 @@ export function TasksPage() { ) : (
-

Select a task or action to view details

+

Select a task to view its progress

)}
diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/mascotFormatters.ts b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/mascotFormatters.ts new file mode 100644 index 000000000..21bb86f18 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/mascotFormatters.ts @@ -0,0 +1,568 @@ +// One-line speech-bubble formatters for the mascot's action display. +// Parallel to the JSX renderers in renderers.tsx — same action set, same +// input/output field extraction, but each formatter produces a small POJO +// (label + body + status) instead of card-sized JSX. +// +// COLOCATED WITH THE RENDERERS ON PURPOSE: SupportedActionName is the +// shared type between the two registries. If you add a new action's +// renderer to REGISTRY in renderers.tsx, you must add it to +// SUPPORTED_ACTION_NAMES — and then TypeScript will force you to add the +// matching formatter to FORMATTER_REGISTRY here too (and vice versa). +// That prevents the two display paths from drifting out of sync. + +import { basename, strField, arrField, dictField, boolField } from './parse' +import { extractTodos, isSupportedActionName, normalizeActionName, type SupportedActionName } from './renderers' + +// ───────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────── + +export type MascotActionStatus = 'running' | 'completed' | 'error' | 'cancelled' + +/** Project the broader runtime ActionStatus union ('running' | 'pending' + * | 'completed' | 'error' | 'cancelled' | 'waiting' | 'paused') down + * to the three-value result domain the mascot formatters understand. + * Anything that isn't explicitly an error or cancel is treated as a + * successful completion. */ +export function toMascotResultStatus(status: string): 'completed' | 'error' | 'cancelled' { + if (status === 'error') return 'error' + if (status === 'cancelled') return 'cancelled' + return 'completed' +} + +/** Output of a formatter: what the speech bubble actually renders. + * - `status` drives the icon (spinner / check / X / ban). + * - `label` is the primary line — sans-serif, slightly heavier. + * - `body` is the optional secondary line — sans by default. + * - `bodyMono: true` switches the body to a monospace font; use for + * paths, commands, regex patterns, and other code-like content. */ +export interface MascotActionFormat { + status: MascotActionStatus + label: string + body?: string + bodyMono?: boolean +} + +/** Each supported action provides two formatter functions — one for the + * "running" phase, one for the "result" phase. Splitting them (instead + * of one function dispatched on status) lets each phase pull only the + * fields it actually needs and keeps the conditional logic minimal. */ +export interface MascotActionFormatter { + running: (input: Record | null) => MascotActionFormat + result: ( + input: Record | null, + output: Record | null, + status: 'completed' | 'error' | 'cancelled', + ) => MascotActionFormat +} + +// ───────────────────────────────────────────────────────────────────── +// String helpers +// ───────────────────────────────────────────────────────────────────── +// `basename` is imported from parse.ts — same util the renderers use. + +/** Truncate `s` to `max` characters, appending an ellipsis on overflow. */ +function trim(s: string, max: number): string { + return s.length > max ? s.slice(0, Math.max(0, max - 1)).trimEnd() + '…' : s +} + +/** Line count for a possibly-empty multi-line string (ignores trailing newline). */ +function lineCount(s: string): number { + if (!s) return 0 + return s.replace(/\n$/, '').split('\n').length +} + +/** Hostname-only view of a URL — strips protocol + path + query, so it + * fits in a one-line bubble body. Falls back to the input on parse error. */ +function hostname(url: string): string { + try { + return new URL(url).hostname + } catch { + return url + } +} + +/** Wrap a string in curly quotes — for displaying user-facing queries + * (search query, prompt, etc.) as conversational text. */ +function quote(s: string): string { + return `“${s}”` +} + +/** Snippet of the first N non-empty characters from a free-form text + * output, joined onto one line. Used for "first words of summary" + * bodies (image description, video summary, etc.). */ +function firstSnippet(text: string, max = 60): string { + const compact = text.trim().replace(/\s+/g, ' ') + return trim(compact, max) +} + +// ───────────────────────────────────────────────────────────────────── +// Per-action formatters +// ───────────────────────────────────────────────────────────────────── +// +// Each formatter pulls the same input/output fields the renderer uses +// (see renderers.tsx), so the source-of-truth for "which fields matter" +// stays in one place per action. + +// FILE OPS ─────────────────────────────────────────────────────────── + +const stream_edit: MascotActionFormatter = { + running: (i) => { + const fp = strField(i, 'file_path') ?? '' + return { status: 'running', label: 'Editing file', body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, + result: (i, _o, s) => { + const fp = strField(i, 'file_path') ?? '' + const verb = s === 'completed' ? 'Edited file' : s === 'error' ? 'Edit failed' : 'Edit cancelled' + return { status: s, label: verb, body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, +} + +const write_file: MascotActionFormatter = { + running: (i) => { + const fp = strField(i, 'file_path') ?? '' + return { status: 'running', label: 'Writing file', body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, + result: (i, _o, s) => { + const fp = strField(i, 'file_path') ?? '' + const verb = s === 'completed' ? 'Wrote file' : s === 'error' ? 'Write failed' : 'Write cancelled' + return { status: s, label: verb, body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, +} + +const read_file: MascotActionFormatter = { + running: (i) => { + const fp = strField(i, 'file_path') ?? '' + return { status: 'running', label: 'Reading file', body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, + result: (i, o, s) => { + const fp = strField(i, 'file_path') ?? '' + const lines = lineCount(strField(o, 'content') ?? '') + if (s !== 'completed') { + const verb = s === 'error' ? 'Read failed' : 'Read cancelled' + return { status: s, label: verb, body: fp ? basename(fp) : undefined, bodyMono: !!fp } + } + return { status: s, label: 'Read file', body: lines > 0 ? `${lines.toLocaleString()} lines` : (fp ? basename(fp) : undefined), bodyMono: lines === 0 && !!fp } + }, +} + +const find_files: MascotActionFormatter = { + running: (i) => { + const pattern = strField(i, 'pattern') ?? '' + return { status: 'running', label: 'Finding files', body: pattern || undefined, bodyMono: true } + }, + result: (i, o, s) => { + const pattern = strField(i, 'pattern') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Find failed' : 'Find cancelled' + return { status: s, label: verb, body: pattern || undefined, bodyMono: true } + } + const count = (arrField(o, 'matches') ?? []).length + return { status: s, label: `Found ${count.toLocaleString()} file${count === 1 ? '' : 's'}`, body: pattern || undefined, bodyMono: true } + }, +} + +const list_folder: MascotActionFormatter = { + running: (i) => { + const path = strField(i, 'path') ?? '' + return { status: 'running', label: 'Listing folder', body: path ? basename(path) || '/' : undefined, bodyMono: !!path } + }, + result: (i, o, s) => { + const path = strField(i, 'path') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'List failed' : 'List cancelled' + return { status: s, label: verb, body: path ? basename(path) || '/' : undefined, bodyMono: !!path } + } + const count = (arrField(o, 'contents') ?? []).length + return { status: s, label: 'Listed folder', body: `${count.toLocaleString()} item${count === 1 ? '' : 's'}` } + }, +} + +const create_pdf: MascotActionFormatter = { + running: (i) => { + const fp = strField(i, 'file_path') ?? '' + return { status: 'running', label: 'Creating PDF', body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, + result: (i, o, s) => { + const fp = strField(o, 'path') ?? strField(i, 'file_path') ?? '' + const verb = s === 'completed' ? 'Created PDF' : s === 'error' ? 'PDF creation failed' : 'PDF creation cancelled' + return { status: s, label: verb, body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, +} + +const read_pdf: MascotActionFormatter = { + running: (i) => { + const fp = strField(i, 'file_path') ?? '' + return { status: 'running', label: 'Reading PDF', body: fp ? basename(fp) : undefined, bodyMono: !!fp } + }, + result: (i, o, s) => { + const fp = strField(i, 'file_path') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'PDF read failed' : 'PDF read cancelled' + return { status: s, label: verb, body: fp ? basename(fp) : undefined, bodyMono: !!fp } + } + const contentObj = dictField(o, 'content') + const elements = arrField(contentObj, 'elements') ?? [] + return { status: s, label: 'Read PDF', body: elements.length > 0 ? `${elements.length} element${elements.length === 1 ? '' : 's'}` : (fp ? basename(fp) : undefined), bodyMono: elements.length === 0 && !!fp } + }, +} + +const convert_to_markdown: MascotActionFormatter = { + running: (i) => { + const src = strField(i, 'input_file') ?? '' + return { status: 'running', label: 'Converting to markdown', body: src ? basename(src) : undefined, bodyMono: !!src } + }, + result: (i, o, s) => { + const out = strField(o, 'md_file') ?? strField(i, 'output_md') ?? '' + const verb = s === 'completed' ? 'Converted to markdown' : s === 'error' ? 'Convert failed' : 'Convert cancelled' + return { status: s, label: verb, body: out ? basename(out) : undefined, bodyMono: !!out } + }, +} + +// CODE EXECUTION ───────────────────────────────────────────────────── + +const run_python: MascotActionFormatter = { + running: (i) => { + const lines = lineCount(strField(i, 'code') ?? '') + return { status: 'running', label: 'Running Python', body: lines > 0 ? `${lines} line${lines === 1 ? '' : 's'}` : undefined } + }, + result: (_i, o, s) => { + const stderr = strField(o, 'stderr') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Python failed' : 'Python cancelled' + return { status: s, label: verb, body: stderr ? firstSnippet(stderr, 60) : undefined } + } + // Successful completion that wrote to stderr usually still indicates + // a problem worth surfacing (e.g., warnings). Show the first stderr + // line; otherwise just say it ran. + if (stderr) return { status: s, label: 'Ran Python', body: firstSnippet(stderr, 60) } + const stdout = strField(o, 'stdout') ?? '' + return { status: s, label: 'Ran Python', body: stdout ? `${lineCount(stdout)} line${lineCount(stdout) === 1 ? '' : 's'} output` : undefined } + }, +} + +const run_shell: MascotActionFormatter = { + running: (i) => { + const cmd = strField(i, 'command') ?? '' + return { status: 'running', label: 'Running command', body: cmd ? `$ ${cmd}` : undefined, bodyMono: true } + }, + result: (i, o, s) => { + const cmd = strField(i, 'command') ?? '' + const verb = s === 'completed' ? 'Ran command' : s === 'error' ? 'Command failed' : 'Command cancelled' + return { status: s, label: verb, body: cmd ? `$ ${cmd}` : undefined, bodyMono: true } + }, +} + +// WEB ──────────────────────────────────────────────────────────────── + +const web_search: MascotActionFormatter = { + running: (i) => { + const q = strField(i, 'query') ?? '' + return { status: 'running', label: 'Searching web', body: q ? quote(q) : undefined } + }, + result: (i, o, s) => { + const q = strField(i, 'query') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Search failed' : 'Search cancelled' + return { status: s, label: verb, body: q ? quote(q) : undefined } + } + const count = (arrField(o, 'results') ?? []).length + return { status: s, label: `Found ${count.toLocaleString()} result${count === 1 ? '' : 's'}`, body: q ? quote(q) : undefined } + }, +} + +const web_fetch: MascotActionFormatter = { + running: (i) => { + const url = strField(i, 'url') ?? '' + return { status: 'running', label: 'Fetching page', body: url ? hostname(url) : undefined } + }, + result: (i, o, s) => { + const url = strField(i, 'url') ?? '' + const title = strField(o, 'title') + if (s !== 'completed') { + const verb = s === 'error' ? 'Fetch failed' : 'Fetch cancelled' + return { status: s, label: verb, body: url ? hostname(url) : undefined } + } + return { status: s, label: title ? `Fetched ${trim(title, 40)}` : 'Fetched page', body: url ? hostname(url) : undefined } + }, +} + +const http_request: MascotActionFormatter = { + running: (i) => { + const method = (strField(i, 'method') ?? 'GET').toUpperCase() + const url = strField(i, 'url') ?? '' + return { status: 'running', label: `${method} request`, body: url ? hostname(url) : undefined } + }, + result: (i, o, s) => { + const method = (strField(i, 'method') ?? 'GET').toUpperCase() + const url = strField(i, 'url') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? `${method} failed` : `${method} cancelled` + return { status: s, label: verb, body: url ? hostname(url) : undefined } + } + const body = strField(o, 'body') ?? '' + return { status: s, label: `${method} response`, body: body ? `${body.length.toLocaleString()} chars` : (url ? hostname(url) : undefined) } + }, +} + +// MEDIA ────────────────────────────────────────────────────────────── + +const generate_image: MascotActionFormatter = { + running: (i) => { + const p = strField(i, 'prompt') ?? '' + return { status: 'running', label: 'Generating image', body: p ? quote(trim(p, 60)) : undefined } + }, + result: (i, o, s) => { + const p = strField(i, 'prompt') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Generation failed' : 'Generation cancelled' + return { status: s, label: verb, body: p ? quote(trim(p, 60)) : undefined } + } + const count = (arrField(o, 'image_paths') ?? []).length + return { status: s, label: `Generated ${count} image${count === 1 ? '' : 's'}`, body: p ? quote(trim(p, 60)) : undefined } + }, +} + +const describe_image: MascotActionFormatter = { + running: (i) => { + const path = strField(i, 'image_path') ?? '' + return { status: 'running', label: 'Describing image', body: path ? basename(path) : undefined, bodyMono: !!path } + }, + result: (i, o, s) => { + const path = strField(i, 'image_path') ?? '' + const desc = strField(o, 'description') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Describe failed' : 'Describe cancelled' + return { status: s, label: verb, body: path ? basename(path) : undefined, bodyMono: !!path } + } + return { status: s, label: 'Described image', body: desc ? firstSnippet(desc, 60) : (path ? basename(path) : undefined), bodyMono: !desc && !!path } + }, +} + +const perform_ocr: MascotActionFormatter = { + running: (i) => { + const path = strField(i, 'image_path') ?? '' + return { status: 'running', label: 'Reading image text', body: path ? basename(path) : undefined, bodyMono: !!path } + }, + result: (i, o, s) => { + const path = strField(i, 'image_path') ?? '' + const summary = strField(o, 'summary') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'OCR failed' : 'OCR cancelled' + return { status: s, label: verb, body: path ? basename(path) : undefined, bodyMono: !!path } + } + return { status: s, label: 'Extracted text', body: summary ? `${summary.length.toLocaleString()} characters` : (path ? basename(path) : undefined), bodyMono: !summary && !!path } + }, +} + +const understand_video: MascotActionFormatter = { + running: (i) => { + const path = strField(i, 'video_path') ?? '' + return { status: 'running', label: 'Analyzing video', body: path ? basename(path) : undefined, bodyMono: !!path } + }, + result: (i, o, s) => { + const path = strField(i, 'video_path') ?? '' + const summary = strField(o, 'summary') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Analysis failed' : 'Analysis cancelled' + return { status: s, label: verb, body: path ? basename(path) : undefined, bodyMono: !!path } + } + return { status: s, label: 'Analyzed video', body: summary ? firstSnippet(summary, 60) : (path ? basename(path) : undefined), bodyMono: !summary && !!path } + }, +} + +// SEARCH ───────────────────────────────────────────────────────────── + +const grep_files: MascotActionFormatter = { + running: (i) => { + const p = strField(i, 'pattern') ?? '' + return { status: 'running', label: 'Grepping', body: p || undefined, bodyMono: true } + }, + result: (i, o, s) => { + const p = strField(i, 'pattern') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Grep failed' : 'Grep cancelled' + return { status: s, label: verb, body: p || undefined, bodyMono: true } + } + const count = (arrField(o, 'filenames') ?? []).length + return { status: s, label: `Found in ${count.toLocaleString()} file${count === 1 ? '' : 's'}`, body: p || undefined, bodyMono: true } + }, +} + +const memory_search: MascotActionFormatter = { + running: (i) => { + const q = strField(i, 'query') ?? '' + return { status: 'running', label: 'Searching memory', body: q ? quote(q) : undefined } + }, + result: (i, o, s) => { + const q = strField(i, 'query') ?? '' + if (s !== 'completed') { + const verb = s === 'error' ? 'Memory search failed' : 'Memory search cancelled' + return { status: s, label: verb, body: q ? quote(q) : undefined } + } + const count = (arrField(o, 'results') ?? []).length + return { status: s, label: `Found ${count.toLocaleString()} memor${count === 1 ? 'y' : 'ies'}`, body: q ? quote(q) : undefined } + }, +} + +// MESSAGING + TASK CONTROL ─────────────────────────────────────────── +// +// send_message and send_message_with_attachment normally bypass these +// formatters — the narration FSM routes them through the 'message' +// phase that displays the user-facing message text directly. These +// fallback formatters exist for type-completeness (Record exhaustive +// over SupportedActionName) and as a sensible last-resort display if +// the message-phase routing is ever bypassed. + +const send_message: MascotActionFormatter = { + running: (i) => { + const msg = strField(i, 'message') ?? '' + return { status: 'running', label: 'Sending message', body: msg ? firstSnippet(msg, 60) : undefined } + }, + result: (_i, _o, s) => { + const verb = s === 'completed' ? 'Sent message' : s === 'error' ? 'Send failed' : 'Send cancelled' + return { status: s, label: verb } + }, +} + +const send_message_with_attachment: MascotActionFormatter = { + running: (i) => { + const count = (arrField(i, 'file_paths') ?? []).length + return { status: 'running', label: 'Sending message', body: count > 0 ? `+ ${count} attachment${count === 1 ? '' : 's'}` : undefined } + }, + result: (i, _o, s) => { + const count = (arrField(i, 'file_paths') ?? []).length + const verb = s === 'completed' ? 'Sent message' : s === 'error' ? 'Send failed' : 'Send cancelled' + return { status: s, label: verb, body: count > 0 ? `+ ${count} attachment${count === 1 ? '' : 's'}` : undefined } + }, +} + +const task_end: MascotActionFormatter = { + // task_end is filtered out of narration upstream — the body reaction + // (celebrate/frustrate animation) is the user-facing signal instead. + // These fallbacks would only ever be reached by a routing bug; they + // produce a plausible bubble rather than a crash. + running: (i) => ({ + status: 'running', + label: 'Finishing up', + body: (strField(i, 'reason') ?? undefined) && firstSnippet(strField(i, 'reason')!, 60), + }), + result: (i, _o, s) => { + const verb = s === 'completed' ? 'Task done' : s === 'error' ? 'Task failed' : 'Task cancelled' + const summary = strField(i, 'summary') ?? '' + return { status: s, label: verb, body: summary ? firstSnippet(summary, 60) : undefined } + }, +} + +// TODOS ────────────────────────────────────────────────────────────── + +const task_update_todos: MascotActionFormatter = { + running: (i) => { + const todos = extractTodos(i) + if (!todos || todos.length === 0) { + return { status: 'running', label: 'Updating todos' } + } + const done = todos.filter(t => t.status === 'completed').length + const inProgress = todos.find(t => t.status === 'in_progress') + const progress = `${done}/${todos.length} done` + const body = inProgress ? `${progress} · ${trim(inProgress.content, 40)}` : progress + return { status: 'running', label: 'Updating todos', body } + }, + result: (i, _o, s) => { + const todos = extractTodos(i) + const verb = s === 'completed' ? 'Updated todos' : s === 'error' ? 'Update failed' : 'Update cancelled' + if (!todos || todos.length === 0) { + return { status: s, label: verb } + } + const done = todos.filter(t => t.status === 'completed').length + return { status: s, label: verb, body: `${done}/${todos.length} done` } + }, +} + +// ───────────────────────────────────────────────────────────────────── +// Registry + lookup +// ───────────────────────────────────────────────────────────────────── + +/** Exhaustive map from every SupportedActionName to its formatter. + * Typed as Record so any new entry in + * SUPPORTED_ACTION_NAMES (in renderers.tsx) becomes a compile error + * here until you add the matching formatter. */ +const FORMATTER_REGISTRY: Record = { + // file ops + stream_edit, + write_file, + read_file, + find_files, + list_folder, + create_pdf, + read_pdf, + convert_to_markdown, + // code execution + run_python, + run_shell, + // web + web_search, + web_fetch, + http_request, + // media + generate_image, + describe_image, + perform_ocr, + understand_video, + // search + grep_files, + memory_search, + // messaging + task control + send_message, + send_message_with_attachment, + task_end, + // todos + task_update_todos, +} + +/** Turn an action name (typically snake_case from the agent's tool + * metadata) into a human-readable phrase: underscores/dashes → spaces, + * collapsed whitespace, first letter capitalized. + * "task_update_todos" → "Task update todos" + * "list-folder" → "List folder" */ +function humanizeActionName(name: string): string { + const spaced = name.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim() + if (!spaced) return 'action' + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +/** Build a generic formatter for an action name that isn't in + * FORMATTER_REGISTRY. The action name is interpolated into the + * label so the user still sees WHICH action is running/finished — + * the only thing we can't do for unknown actions is read meaningful + * fields out of their input/output payloads. */ +function makeGenericFormatter(name: string): MascotActionFormatter { + const humanized = humanizeActionName(name) + return { + running: () => ({ status: 'running', label: `Running ${humanized}` }), + result: (_i, _o, s) => ({ + status: s, + label: s === 'completed' ? `${humanized} completed` + : s === 'error' ? `${humanized} failed` + : `${humanized} cancelled`, + }), + } +} + +/** Final-final fallback used when the action has no name at all + * (defensive — shouldn't happen in practice, but keeps the bubble + * alive instead of crashing if it does). */ +const GENERIC_FORMATTER: MascotActionFormatter = makeGenericFormatter('action') + +/** Look up the formatter for an action name. Falls back to a + * name-aware generic formatter for any name not in the supported + * set — that's intentional: actions without a custom renderer in + * renderers.tsx should ALSO not have custom-extraction bubble + * content (the user said: "ONLY handle actions that is also handled + * in the action renderer"). But the generic version still SHOWS the + * action name so the user knows what's happening. */ +export function getMascotFormatter(name: string | undefined): MascotActionFormatter { + if (!name) return GENERIC_FORMATTER + const normalized = normalizeActionName(name) + return isSupportedActionName(normalized) ? FORMATTER_REGISTRY[normalized] : makeGenericFormatter(name) +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts new file mode 100644 index 000000000..ad2852f17 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts @@ -0,0 +1,194 @@ +// Parsing helpers shared by all action renderers. +// +// Action input/output strings arrive as either JSON or Python dict repr +// (e.g. `{'file_path': 'C:\\...', 'count': 42, 'flag': True, 'val': None}`). +// `parseDict` is lenient — it tries JSON first, then falls back to a small +// Python-literal parser that handles single-quoted strings and True/False/None. + +export function parseDict(content: string | undefined | null): Record | null { + if (!content) return null + try { + const parsed = JSON.parse(content) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // fall through to Python dict parser + } + + const result: Record = {} + let inner = content.trim() + if (inner.startsWith('{') && inner.endsWith('}')) { + inner = inner.slice(1, -1).trim() + } else { + return null + } + + let i = 0 + while (i < inner.length) { + while (i < inner.length && (inner[i] === ' ' || inner[i] === ',' || inner[i] === '\n')) i++ + if (i >= inner.length) break + + const keyQuote = inner[i] + if (keyQuote !== "'" && keyQuote !== '"') { + i++ + continue + } + i++ + + let key = '' + while (i < inner.length && inner[i] !== keyQuote) { + if (inner[i] === '\\' && i + 1 < inner.length) { + key += inner[i + 1] + i += 2 + } else { + key += inner[i] + i++ + } + } + i++ + + while (i < inner.length && (inner[i] === ':' || inner[i] === ' ')) i++ + if (i >= inner.length) break + + let value: unknown + const valueStart = inner[i] + + if (valueStart === "'" || valueStart === '"') { + i++ + let str = '' + while (i < inner.length && inner[i] !== valueStart) { + if (inner[i] === '\\' && i + 1 < inner.length) { + const next = inner[i + 1] + if (next === 'n') str += '\n' + else if (next === 't') str += '\t' + else if (next === 'r') str += '\r' + else str += next + i += 2 + } else { + str += inner[i] + i++ + } + } + i++ + value = str + } else if (valueStart === '{') { + let depth = 1 + const start = i + i++ + while (i < inner.length && depth > 0) { + if (inner[i] === '{') depth++ + else if (inner[i] === '}') depth-- + i++ + } + value = parseDict(inner.slice(start, i)) ?? inner.slice(start, i) + } else if (valueStart === '[') { + let depth = 1 + const start = i + i++ + while (i < inner.length && depth > 0) { + if (inner[i] === '[') depth++ + else if (inner[i] === ']') depth-- + i++ + } + // Store array slices as raw string; renderers that need it can call parseArray. + value = inner.slice(start, i) + } else { + let raw = '' + while (i < inner.length && inner[i] !== ',' && inner[i] !== '}') { + raw += inner[i] + i++ + } + raw = raw.trim() + if (raw === 'True') value = true + else if (raw === 'False') value = false + else if (raw === 'None') value = null + else if (!isNaN(Number(raw))) value = Number(raw) + else value = raw + } + + if (key) result[key] = value + } + + return result +} + +// Parses a raw "[...]" literal — used to recover arrays that parseDict left +// as raw strings (its top-level dict parser only stores nested arrays as +// slices to keep the implementation small). +export function parseArray(raw: unknown): unknown[] | null { + if (Array.isArray(raw)) return raw + if (typeof raw !== 'string') return null + + const trimmed = raw.trim() + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) return parsed + } catch { + // fall through + } + try { + const normalized = trimmed + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + .replace(/\bNone\b/g, 'null') + .replace(/'/g, '"') + const parsed = JSON.parse(normalized) + if (Array.isArray(parsed)) return parsed + } catch { + // ignore + } + return null +} + +// Typed accessors — keep renderers readable. +export function strField(obj: Record | null, key: string): string | undefined { + const v = obj?.[key] + return typeof v === 'string' ? v : undefined +} + +export function boolField(obj: Record | null, key: string): boolean | undefined { + const v = obj?.[key] + if (typeof v === 'boolean') return v + // Python `True`/`False` may come through as raw word tokens when parseDict + // bailed out — treat them defensively. + if (typeof v === 'string') { + if (v === 'True' || v === 'true') return true + if (v === 'False' || v === 'false') return false + } + return undefined +} + +export function arrField(obj: Record | null, key: string): unknown[] | null { + return parseArray(obj?.[key]) +} + +export function dictField(obj: Record | null, key: string): Record | null { + const v = obj?.[key] + if (v && typeof v === 'object' && !Array.isArray(v)) { + return v as Record + } + if (typeof v === 'string') { + return parseDict(v) + } + return null +} + +// Best-effort filename extraction from a path (handles both / and \). +export function basename(path: string): string { + const m = path.match(/[/\\]([^/\\]+)$/) + return m ? m[1] : path +} + +// Detect a programming language from a file extension. Used to label code +// blocks; not a real syntax highlighter (yet). +export function langFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? '' + return ({ + py: 'python', js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx', + md: 'markdown', html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', + sh: 'bash', bash: 'bash', ps1: 'powershell', sql: 'sql', go: 'go', + rs: 'rust', rb: 'ruby', java: 'java', c: 'c', cpp: 'cpp', h: 'c', + txt: 'text', csv: 'csv', toml: 'toml', xml: 'xml', + } as Record)[ext] ?? ext ?? 'text' +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css new file mode 100644 index 000000000..9920de3e2 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css @@ -0,0 +1,365 @@ +/* Shared UI primitives for action renderers. + The
wrapper reuses TasksPage's .actionSection/.ioLabel from the + parent module — only the renderer-specific primitives live here. */ + +/* File path chip — monospace, click to open */ +/* File link — borderless, transparent, primary color, underline on hover. + text-sm so it reads at link weight, not metadata weight. */ +.filePathChip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-primary); + background: transparent; + padding: 0; + border: none; + cursor: pointer; + max-width: 100%; + text-align: left; + text-decoration: none; + transition: text-decoration var(--transition-fast); +} + +.filePathChip:hover { + text-decoration: underline; +} + +.filePathChip.nonInteractive { + cursor: default; + color: var(--text-primary); +} + +.filePathChip.nonInteractive:hover { + text-decoration: none; +} + +.filePathChipPath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; /* keep filename visible when truncating long paths */ + text-align: left; + min-width: 0; +} + +/* URL link — same shape as the file link: borderless, transparent. */ +.urlChip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--color-primary); + background: transparent; + padding: 0; + text-decoration: none; + max-width: 100%; + transition: text-decoration var(--transition-fast); +} + +.urlChip:hover { + text-decoration: underline; +} + +.urlChipText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Generic code block — monospace + bg, optional language tag */ +.codeBlock { + position: relative; + margin: 0; + padding: var(--space-2) var(--space-3); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + max-height: 320px; + overflow-y: auto; +} + +.codeBlockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + font-family: var(--font-sans, inherit); + font-size: var(--text-xs); + color: var(--text-muted); + margin-bottom: var(--space-1); +} + +.codeBlockLang { + font-weight: var(--font-medium); + text-transform: lowercase; +} + +/* Terminal-style output (run_python, run_shell) — darker bg, no max width */ +.terminal { + margin: 0; + padding: var(--space-2) var(--space-3); + background: #0b0b0b; + color: #e5e5e5; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-all; + max-height: 320px; + overflow-y: auto; +} + +/* stderr stays grayscale — italic + dimmed opacity is enough to set it apart + from stdout without relying on red. */ +.terminalStderr { + color: #e5e5e5; + opacity: 0.65; + font-style: italic; +} + +.terminalDim { + color: #6b7280; + font-style: italic; +} + +/* Result list (web_search, memory_search, grep) */ +.resultList { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.resultCard { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + text-decoration: none; + color: inherit; + border: 1px solid transparent; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.resultCard:hover { + border-color: var(--border-secondary); + background: var(--bg-secondary); +} + +.resultTitle { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resultUrl { + font-size: var(--text-xs); + color: var(--text-muted); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resultSnippet { + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-normal); + margin-top: 2px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Image thumbnail — click to open fullscreen */ +.imageThumb { + display: block; + max-width: 240px; + max-height: 240px; + border-radius: var(--radius-md); + cursor: zoom-in; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + object-fit: contain; +} + +.imageThumbsRow { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Fullscreen image overlay */ +.imageOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: zoom-out; + animation: imageFadeIn 0.12s ease-out; +} + +.imageOverlayImg { + max-width: 95vw; + max-height: 95vh; + border-radius: var(--radius-md); + object-fit: contain; +} + +@keyframes imageFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Status / meta pills */ +.metaRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + font-size: var(--text-xs); + color: var(--text-secondary); +} + +.metaPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px var(--space-2); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + color: var(--text-primary); +} + +/* Variant pills inherit the neutral .metaPill look — grayscale only. The + variant prop is preserved on the component API so renderers stay + semantically clear, but no color differentiation is applied. The + accompanying StatusIndicator icon already conveys success/error/etc. */ +.metaPillSuccess, +.metaPillError, +.metaPillWarn { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Diff view (stream_edit) */ +.diffBlock { + margin: 0; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.55; + overflow: hidden; + max-height: 360px; + overflow-y: auto; +} + +.diffLine { + display: block; + padding: 0 var(--space-3); + white-space: pre-wrap; + word-break: break-all; +} + +/* Conventional diff colors — red for removed, green for added. */ +.diffLineRemoved { + background: rgba(239, 68, 68, 0.15); + color: rgb(252, 165, 165); +} + +.diffLineAdded { + background: rgba(16, 185, 129, 0.15); + color: rgb(110, 231, 183); +} + +.diffLineRemoved::before { + content: '- '; + color: rgb(239, 68, 68); +} + +.diffLineAdded::before { + content: '+ '; + color: rgb(16, 185, 129); +} + +.diffSeparator { + padding: var(--space-1) var(--space-3); + color: var(--text-muted); + font-style: italic; + font-size: var(--text-xs); + border-top: 1px solid var(--border-primary); +} + +/* Directory tree — monospace listing with ├── / └── branch characters, + used by list_folder so the entries read like `tree` command output. */ +.dirTree { + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.7; + color: var(--text-primary); + white-space: pre; +} + +.dirTreeBranch { + color: var(--text-muted); +} + +.dirTreeFolder { + color: var(--text-primary); + font-weight: var(--font-medium); +} + +/* Inline collapsible "show more" toggle */ +.expandToggle { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--color-primary); + font-size: var(--text-xs); + cursor: pointer; + text-decoration: underline; +} + +.expandToggle:hover { + color: var(--color-primary-hover, var(--color-primary)); +} + +/* Pending state text and image/video captions (the user's prompt/query + shown below the media). Same size as body content; italic+muted gives a + gentle hierarchy hint without breaking the uniform sizing. */ +.pendingPlaceholder { + font-size: var(--text-sm); + color: var(--text-muted); + font-style: italic; +} + +/* Subtle "wait_for_user_reply" hint for the message renderers — a small + muted line describing the message's configuration, not the agent's + current state, so it stays accurate after the user has replied. */ +.waitForReplyPill { + display: inline-block; + margin-top: var(--space-1); + font-size: var(--text-xs); + font-style: italic; + color: var(--text-muted); +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx new file mode 100644 index 000000000..d8adf9025 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx @@ -0,0 +1,361 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ExternalLink, File } from 'lucide-react' +import { Highlight, themes, type Language } from 'prism-react-renderer' +import { basename } from './parse' +import styles from './primitives.module.css' +import tasksStyles from '../TasksPage.module.css' + +// ───────────────────────────────────────────────────────────────────── +// Section wrapper used by renderers. Reuses TasksPage's .actionSection / +// .ioLabel so the renderer's sections sit alongside ActionBlock's own +// Error section + expanded detail panel with consistent borders (the +// .actionSection + .actionSection adjacent-sibling rule kicks in). +// ───────────────────────────────────────────────────────────────────── + +interface SectionProps { + label?: string + children: React.ReactNode +} + +export function Section({ label, children }: SectionProps) { + return ( +
+ {label &&
{label}
} + {children} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// File path chip: shows the basename prominently; full path on hover via +// the title attribute. Clickable when an onOpen handler is supplied. +// ───────────────────────────────────────────────────────────────────── + +interface FilePathChipProps { + path: string + onOpen?: (path: string) => void + showFull?: boolean // show full path instead of basename +} + +export function FilePathChip({ path, onOpen, showFull }: FilePathChipProps) { + const label = showFull ? path : basename(path) + const interactive = !!onOpen + return ( + + ) +} + +// ───────────────────────────────────────────────────────────────────── +// URL chip: opens in a new tab. +// ───────────────────────────────────────────────────────────────────── + +interface UrlChipProps { + url: string + label?: string +} + +export function UrlChip({ url, label }: UrlChipProps) { + return ( + + + ) +} + +// ───────────────────────────────────────────────────────────────────── +// CodeBlock: syntax-highlighted code via prism-react-renderer. Falls back +// to plain monospace when the language isn't recognised by Prism. +// ───────────────────────────────────────────────────────────────────── + +// Prism recognises a fixed set of language ids. Map our looser ids onto +// Prism's vocabulary; anything unknown is rendered without highlighting. +const PRISM_LANG_MAP: Record = { + python: 'python', + py: 'python', + javascript: 'javascript', + js: 'javascript', + typescript: 'typescript', + ts: 'typescript', + tsx: 'tsx', + jsx: 'jsx', + markdown: 'markdown', + md: 'markdown', + html: 'markup', + xml: 'markup', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + bash: 'bash', + sh: 'bash', + shell: 'bash', + sql: 'sql', + go: 'go', + rust: 'rust', + rs: 'rust', + ruby: 'ruby', + rb: 'ruby', + java: 'java', + c: 'c', + cpp: 'cpp', +} + +interface CodeBlockProps { + code: string + lang?: string +} + +export function CodeBlock({ code, lang }: CodeBlockProps) { + const prismLang = lang ? PRISM_LANG_MAP[lang.toLowerCase()] : undefined + + // No recognised language → plain monospace block. + if (!prismLang) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
{code}
+
+ ) + } + + return ( +
+
+ {lang} +
+ + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+            {tokens.map((line, i) => (
+              
+ {line.map((token, j) => ( + + ))} +
+ ))} +
+ )} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Terminal: stdout + (optional) stderr, dark bg. +// ───────────────────────────────────────────────────────────────────── + +interface TerminalProps { + stdout?: string + stderr?: string +} + +export function Terminal({ stdout, stderr }: TerminalProps) { + const hasOut = !!(stdout && stdout.length > 0) + const hasErr = !!(stderr && stderr.length > 0) + if (!hasOut && !hasErr) { + return
(no output)
+ } + return ( +
+      {hasOut && {stdout}}
+      {hasOut && hasErr && '\n'}
+      {hasErr && {stderr}}
+    
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// ResultList / ResultCard for search-style outputs. +// ───────────────────────────────────────────────────────────────────── + +interface ResultCardProps { + title: string + url?: string + snippet?: string + onClick?: () => void +} + +export function ResultCard({ title, url, snippet, onClick }: ResultCardProps) { + const content = ( + <> +
{title}
+ {url &&
{url}
} + {snippet &&
{snippet}
} + + ) + if (url) { + return ( + + {content} + + ) + } + return ( +
+ {content} +
+ ) +} + +export function ResultList({ children }: { children: React.ReactNode }) { + return
{children}
+} + +// ───────────────────────────────────────────────────────────────────── +// ImageThumbnail: click → fullscreen overlay (close on click or Esc). +// ───────────────────────────────────────────────────────────────────── + +interface ImageThumbnailProps { + src: string + alt?: string +} + +export function ImageThumbnail({ src, alt }: ImageThumbnailProps) { + const [open, setOpen] = useState(false) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open]) + + return ( + <> + {alt setOpen(true)} + loading="lazy" + /> + {open && ( +
setOpen(false)}> + {alt +
+ )} + + ) +} + +export function ImageThumbnailRow({ srcs, alt }: { srcs: string[]; alt?: string }) { + return ( +
+ {srcs.map((s, i) => )} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Meta pills: small status/info chips (status_code, exit_code, etc.). +// ───────────────────────────────────────────────────────────────────── + +type PillVariant = 'default' | 'success' | 'error' | 'warn' + +export function MetaPill({ children, variant = 'default' }: { children: React.ReactNode; variant?: PillVariant }) { + const cls = + variant === 'success' ? `${styles.metaPill} ${styles.metaPillSuccess}` : + variant === 'error' ? `${styles.metaPill} ${styles.metaPillError}` : + variant === 'warn' ? `${styles.metaPill} ${styles.metaPillWarn}` : + styles.metaPill + return {children} +} + +export function MetaRow({ children }: { children: React.ReactNode }) { + return
{children}
+} + +// ───────────────────────────────────────────────────────────────────── +// DiffView: minimal old/new diff rendered as added/removed lines, with +// a "..." separator between them. We're not running a real Myers diff +// here — stream_edit gives us the literal old_string / new_string pair, +// so showing them as a remove-block-then-add-block is honest about what +// the action replaces. +// ───────────────────────────────────────────────────────────────────── + +interface DiffViewProps { + oldText: string + newText: string +} + +export function DiffView({ oldText, newText }: DiffViewProps) { + const oldLines = useMemo(() => oldText.split('\n'), [oldText]) + const newLines = useMemo(() => newText.split('\n'), [newText]) + return ( +
+ {oldLines.map((line, i) => ( + {line || ' '} + ))} + {newLines.map((line, i) => ( + {line || ' '} + ))} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Show-more toggle for long bodies (markdown previews etc.). +// ───────────────────────────────────────────────────────────────────── + +interface CollapsibleProps { + text: string + collapsedLines?: number +} + +export function Collapsible({ text, collapsedLines = 5 }: CollapsibleProps) { + const [expanded, setExpanded] = useState(false) + const lines = text.split('\n') + const isLong = lines.length > collapsedLines + const shown = expanded || !isLong ? text : lines.slice(0, collapsedLines).join('\n') + '\n…' + + return ( + <> +
{shown}
+ {isLong && ( + + )} + + ) +} + +export function Pending({ label }: { label?: string }) { + return
{label ?? 'Waiting for result…'}
+} + +// Subtle hint that the message action was sent with wait_for_user_reply +// set to true. Phrased as a description of the message itself ("this message +// waits …"), not the agent's current state — so it stays accurate even +// after the user has replied. +export function WaitForReplyPill() { + return ( + + This message waits for user's reply + + ) +} + +// Re-export the styles object so renderer files in this folder can also use +// a few specific classes (e.g. .section) for ad-hoc layouts. +export { styles as primStyles } diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx new file mode 100644 index 000000000..f1401c4e1 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx @@ -0,0 +1,781 @@ +import React from 'react' +import { Square, CheckSquare } from 'lucide-react' +import type { ActionItem } from '../../../types' +import { + Section, FilePathChip, UrlChip, CodeBlock, Terminal, ResultList, ResultCard, + ImageThumbnail, ImageThumbnailRow, MetaPill, MetaRow, DiffView, Collapsible, + Pending, WaitForReplyPill, primStyles, +} from './primitives' +import { strField, boolField, arrField, dictField, langFromPath, parseDict } from './parse' + +// Renderer contract: receives the action item + pre-parsed input/output dicts +// (each is null if absent or unparseable). Returns the JSX that goes inside +// .actionBox (replacing the default Input/Output sections). The error section +// and "More detail" expansion are still added by ActionBlock. +export interface ActionRendererProps { + item: ActionItem + inputObj: Record | null + outputObj: Record | null + onOpenFile: (path: string) => void +} + +export type ActionRenderer = React.FC + +// Turn a workspace-side absolute or relative path into a URL the static +// `/api/workspace/{path}` handler can serve. The server's _validate_path +// rejects anything outside the workspace, so external paths will 404 here — +// the onError fallback renders a placeholder instead of a broken icon. +function toWorkspaceUrl(absPath: string): string { + const normalized = absPath.replace(/\\/g, '/').replace(/^\/+/, '') + return `/api/workspace/${encodeURI(normalized)}` +} + +// ───────────────────────────────────────────────────────────────────── +// FILE OPS +// ───────────────────────────────────────────────────────────────────── + +const StreamEditRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const oldStr = strField(inputObj, 'old_string') ?? '' + const newStr = strField(inputObj, 'new_string') ?? '' + + return ( + <> +
+ {filePath + ? + : } +
+
+ {oldStr || newStr + ? + : } +
+ + ) +} + +const WriteFileRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(inputObj, 'content') ?? '' + + return ( + <> +
+ {filePath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const ReadFileRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(outputObj, 'content') + + return ( + <> +
+ {filePath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const FindFilesRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const pattern = strField(inputObj, 'pattern') ?? '' + const matches = (arrField(outputObj, 'matches') ?? []).filter((m): m is string => typeof m === 'string') + + return ( + <> +
+ {pattern ? {pattern} : } +
+
+ {!outputObj + ? + : matches.length === 0 + ? + : ( +
+ {matches.map((m, i) => )} +
+ )} +
+ + ) +} + +const ListFolderRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const path = strField(inputObj, 'path') ?? '' + const contents = (arrField(outputObj, 'contents') ?? []).filter((c): c is string => typeof c === 'string') + // list_folder returns names only, not types — fall back to a simple + // extension heuristic so folder entries get a trailing slash. + const sorted = [...contents].sort((a, b) => { + const aIsFolder = !a.includes('.') + const bIsFolder = !b.includes('.') + if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1 + return a.localeCompare(b) + }) + + return ( + <> +
+ {path + ? + : } +
+
+ {!outputObj + ? + : sorted.length === 0 + ? + : ( +
+ {sorted.map((name, i) => { + const isLast = i === sorted.length - 1 + const branch = isLast ? '└── ' : '├── ' + const isFolder = !name.includes('.') + return ( +
+ {branch} + + {isFolder ? `${name}/` : name} + +
+ ) + })} +
+ )} +
+ + ) +} + +const CreatePdfRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(inputObj, 'content') ?? '' + const outPath = strField(outputObj, 'path') ?? filePath + + return ( + <> +
+ {outPath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const ReadPdfRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const contentObj = dictField(outputObj, 'content') + const elements = arrField(contentObj, 'elements') ?? [] + // Concatenate the extracted text from each layout element so the user sees + // the file's actual content, not just page counts. + const text = elements + .map(el => (el && typeof el === 'object' && !Array.isArray(el) + ? strField(el as Record, 'text') + : null)) + .filter((t): t is string => !!t) + .join('\n') + + return ( + <> +
+ {filePath + ? + : } +
+
+ {!outputObj + ? + : text + ? + : } +
+ + ) +} + +const ConvertToMarkdownRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const input = strField(inputObj, 'input_file') ?? '' + const output = strField(outputObj, 'md_file') ?? strField(inputObj, 'output_md') ?? '' + + return ( + <> +
+ {input + ? + : } +
+
+ {output + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// CODE EXECUTION +// ───────────────────────────────────────────────────────────────────── + +const RunPythonRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const code = strField(inputObj, 'code') ?? '' + const stdout = strField(outputObj, 'stdout') ?? '' + const stderr = strField(outputObj, 'stderr') ?? '' + + return ( + <> +
+ {code + ? + : } +
+
+ {outputObj + ? + : } +
+ + ) +} + +const RunShellRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const command = strField(inputObj, 'command') ?? '' + const stdout = strField(outputObj, 'stdout') ?? '' + const stderr = strField(outputObj, 'stderr') ?? '' + + return ( + <> +
+ {command + ? + : } +
+
+ {outputObj + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// WEB +// ───────────────────────────────────────────────────────────────────── + +const WebSearchRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const query = strField(inputObj, 'query') ?? '' + const results = (arrField(outputObj, 'results') ?? []).filter( + (r): r is Record => !!r && typeof r === 'object' && !Array.isArray(r), + ) + + return ( + <> +
+ {query ?
{query}
: } +
+
+ {!outputObj + ? + : results.length === 0 + ? + : ( + + {results.map((r, i) => ( + + ))} + + )} +
+ + ) +} + +const WebFetchRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const url = strField(inputObj, 'url') ?? '' + const title = strField(outputObj, 'title') + const content = strField(outputObj, 'content') ?? '' + + return ( + <> +
+ {url + ? + : } +
+
+ {!outputObj + ? + : content + ? + : } +
+ + ) +} + +const HttpRequestRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const method = (strField(inputObj, 'method') ?? 'GET').toUpperCase() + const url = strField(inputObj, 'url') ?? '' + const body = strField(inputObj, 'data') + ?? (inputObj?.json ? JSON.stringify(inputObj.json, null, 2) : '') + const respBody = strField(outputObj, 'body') ?? '' + + return ( + <> +
+ + {method} + {url && } + + {body && } +
+
+ {!outputObj + ? + : respBody + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// MEDIA +// ───────────────────────────────────────────────────────────────────── + +const GenerateImageRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const prompt = strField(inputObj, 'prompt') ?? '' + const paths = (arrField(outputObj, 'image_paths') ?? []).filter((p): p is string => typeof p === 'string') + + return ( + <> +
+ {prompt ?
{prompt}
: } +
+
+ {!outputObj + ? + : paths.length === 0 + ? + : } +
+ + ) +} + +const DescribeImageRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const imagePath = strField(inputObj, 'image_path') ?? '' + const prompt = strField(inputObj, 'prompt') + const description = strField(outputObj, 'description') ?? '' + + return ( + <> +
+ {imagePath + ? + : } + {prompt &&
{prompt}
} +
+
+ {!outputObj + ? + : description + ? + : } +
+ + ) +} + +const PerformOcrRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const imagePath = strField(inputObj, 'image_path') ?? '' + const summary = strField(outputObj, 'summary') ?? '' + + return ( + <> +
+ {imagePath + ? + : } +
+
+ {!outputObj + ? + : summary + ? + : } +
+ + ) +} + +const UnderstandVideoRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const videoPath = strField(inputObj, 'video_path') ?? '' + const query = strField(inputObj, 'query') + const summary = strField(outputObj, 'summary') ?? '' + + return ( + <> +
+ {videoPath + ? + : } + {query &&
{query}
} +
+
+ {!outputObj + ? + : summary + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// SEARCH +// ───────────────────────────────────────────────────────────────────── + +const GrepFilesRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const pattern = strField(inputObj, 'pattern') ?? '' + const mode = strField(inputObj, 'output_mode') ?? 'files_with_matches' + const filenames = (arrField(outputObj, 'filenames') ?? []).filter((f): f is string => typeof f === 'string') + const content = strField(outputObj, 'content') + + return ( + <> +
+ {pattern ? {pattern} : } +
+
+ {!outputObj + ? + : mode === 'content' && content + ? + : filenames.length > 0 + ? ( +
+ {filenames.map((f, i) => )} +
+ ) + : } +
+ + ) +} + +const MemorySearchRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const query = strField(inputObj, 'query') ?? '' + const results = (arrField(outputObj, 'results') ?? []).filter( + (r): r is Record => !!r && typeof r === 'object' && !Array.isArray(r), + ) + + return ( + <> +
+ {query ?
{query}
: } +
+
+ {!outputObj + ? + : results.length === 0 + ? + : ( + + {results.map((r, i) => { + const title = strField(r, 'title') ?? strField(r, 'section_path') ?? '(memory)' + const filePath = strField(r, 'file_path') + const summary = strField(r, 'summary') + return ( + onOpenFile(filePath) : undefined} + /> + ) + })} + + )} +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// MESSAGING + TASK CONTROL +// ───────────────────────────────────────────────────────────────────── + +const SendMessageRenderer: ActionRenderer = ({ inputObj }) => { + const message = strField(inputObj, 'message') ?? '' + const waitForReply = boolField(inputObj, 'wait_for_user_reply') + + return ( +
+ {message + ?
{message}
+ : } + {waitForReply && } +
+ ) +} + +const SendMessageWithAttachmentRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const message = strField(inputObj, 'message') ?? '' + const waitForReply = boolField(inputObj, 'wait_for_user_reply') + const filePaths = (arrField(inputObj, 'file_paths') ?? []) + .filter((p): p is string => typeof p === 'string') + + return ( + <> +
+ {message + ?
{message}
+ : } + {waitForReply && } +
+
+ {filePaths.length === 0 + ? + : ( +
+ {filePaths.map((p, i) => )} +
+ )} +
+ + ) +} + +const TaskEndRenderer: ActionRenderer = ({ inputObj }) => { + const reason = strField(inputObj, 'reason') ?? '' + const summary = strField(inputObj, 'summary') ?? '' + + return ( + <> + {reason && ( +
+
{reason}
+
+ )} +
+ {summary + ?
{summary}
+ : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// TASK UPDATE TODOS +// ───────────────────────────────────────────────────────────────────── + +export interface TodoEntry { content: string; status: string } + +export function extractTodos(inputObj: Record | null): TodoEntry[] | null { + if (!inputObj) return null + const raw = inputObj.todos + let items: unknown[] | null = null + if (Array.isArray(raw)) items = raw + else if (typeof raw === 'string') { + try { + const norm = raw + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + .replace(/\bNone\b/g, 'null') + .replace(/'/g, '"') + const parsed = JSON.parse(norm) + if (Array.isArray(parsed)) items = parsed + } catch { /* fall through */ } + } + if (!items) return null + const todos: TodoEntry[] = [] + for (const it of items) { + if (it && typeof it === 'object') { + const obj = it as Record + if (typeof obj.content === 'string' && typeof obj.status === 'string') { + todos.push({ content: obj.content, status: obj.status }) + } + } + } + return todos.length > 0 ? todos : null +} + +const TaskUpdateTodosRenderer: ActionRenderer = ({ inputObj }) => { + const todos = extractTodos(inputObj) + if (!todos) return
+ return ( +
+
    + {todos.map((t, i) => { + const done = t.status === 'completed' + const inProgress = t.status === 'in_progress' + return ( +
  • + + {done ? : } + + {t.content} +
  • + ) + })} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// REGISTRY +// ───────────────────────────────────────────────────────────────────── + +/** Canonical list of every action name the Tasks panel renderer knows about. + * This is the SOURCE OF TRUTH for "actions with custom rendering" across + * the app — the mascot's speech-bubble formatter registry (in + * mascotFormatters.ts) is typed against `SupportedActionName` so it must + * cover the same set. If you add a new renderer below, add its name here + * too and the compiler will force you to add the matching mascot formatter + * (and vice versa). That prevents the two display systems from drifting. */ +export const SUPPORTED_ACTION_NAMES = [ + // file ops + 'stream_edit', + 'write_file', + 'read_file', + 'find_files', + 'list_folder', + 'create_pdf', + 'read_pdf', + 'convert_to_markdown', + // code execution + 'run_python', + 'run_shell', + // web + 'web_search', + 'web_fetch', + 'http_request', + // media + 'generate_image', + 'describe_image', + 'perform_ocr', + 'understand_video', + // search + 'grep_files', + 'memory_search', + // messaging + task control + 'send_message', + 'send_message_with_attachment', + 'task_end', + // todos + 'task_update_todos', +] as const + +export type SupportedActionName = typeof SUPPORTED_ACTION_NAMES[number] + +/** Normalize an incoming action name (which may arrive snake_case, with + * spaces, or with dashes from skill metadata) into its canonical form. */ +export function normalizeActionName(name: string): string { + return name.toLowerCase().replace(/[\s-]+/g, '_') +} + +/** Type guard — checks at runtime whether a normalized name is one of + * the known action names. Used to safely index the registries. */ +export function isSupportedActionName(name: string): name is SupportedActionName { + return (SUPPORTED_ACTION_NAMES as readonly string[]).includes(name) +} + +const REGISTRY: Record = { + // file ops + stream_edit: StreamEditRenderer, + write_file: WriteFileRenderer, + read_file: ReadFileRenderer, + find_files: FindFilesRenderer, + list_folder: ListFolderRenderer, + create_pdf: CreatePdfRenderer, + read_pdf: ReadPdfRenderer, + convert_to_markdown: ConvertToMarkdownRenderer, + // code execution + run_python: RunPythonRenderer, + run_shell: RunShellRenderer, + // web + web_search: WebSearchRenderer, + web_fetch: WebFetchRenderer, + http_request: HttpRequestRenderer, + // media + generate_image: GenerateImageRenderer, + describe_image: DescribeImageRenderer, + perform_ocr: PerformOcrRenderer, + understand_video: UnderstandVideoRenderer, + // search + grep_files: GrepFilesRenderer, + memory_search: MemorySearchRenderer, + // messaging + task control + send_message: SendMessageRenderer, + send_message_with_attachment: SendMessageWithAttachmentRenderer, + task_end: TaskEndRenderer, + // todos + task_update_todos: TaskUpdateTodosRenderer, +} + +/** Look up a renderer by action name. Name comparison is loose — names + * may arrive as snake_case (canonical) or with spaces/dashes from skill + * metadata, so we normalize first. Returns null for unknown names. */ +export function getActionRenderer(name: string | undefined): ActionRenderer | null { + if (!name) return null + const normalized = normalizeActionName(name) + return isSupportedActionName(normalized) ? REGISTRY[normalized] : null +} + +// Convenience: parse both input and output as dicts. Returned null on parse +// failure so callers can fall back to the generic ContentDisplay. +export function parseIO(item: ActionItem) { + return { + inputObj: parseDict(item.input), + outputObj: parseDict(item.output), + } +} diff --git a/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts b/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts new file mode 100644 index 000000000..4666ab937 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts @@ -0,0 +1,35 @@ +import type { ActionItem, ActionStatus } from '../types' + +// Describes the trailing placeholder shown at the end of a transcript/list +// while the task is still active. Returns null for terminal task states +// (completed / error / cancelled / pending / idle). +export interface ActivePlaceholder { + label: string + status: ActionStatus +} + +// `children` are the task's action/reasoning items. The "Thinking…" +// placeholder is suppressed when one of them is currently `running` — the +// running action itself already represents the agent's activity, so a +// separate placeholder would be redundant (and would dangle the timeline's +// dashed line below it with nothing to connect to). The waiting/paused +// placeholders are returned unconditionally because they convey a +// user-facing wait state and host the Reply button. +export function getActivePlaceholder( + status: ActionStatus, + children?: ActionItem[], +): ActivePlaceholder | null { + switch (status) { + case 'running': { + const hasRunningChild = children?.some(c => c.status === 'running') ?? false + if (hasRunningChild) return null + return { label: 'Thinking…', status: 'running' } + } + case 'waiting': + return { label: 'Waiting for your reply…', status: 'waiting' } + case 'paused': + return { label: 'Paused — awaiting confirmation…', status: 'paused' } + default: + return null + } +} diff --git a/app/ui_layer/browser/frontend/tsconfig.json b/app/ui_layer/browser/frontend/tsconfig.json index 5413626cc..8cdb46611 100644 --- a/app/ui_layer/browser/frontend/tsconfig.json +++ b/app/ui_layer/browser/frontend/tsconfig.json @@ -17,9 +17,17 @@ "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { - "@/*": ["src/*"] - } + "@/*": ["src/*"], + "@mascot": ["../../components/Mascot"], + "@mascot/*": ["../../components/Mascot/*"], + "react": ["node_modules/@types/react", "node_modules/react"], + "react/jsx-runtime": ["node_modules/@types/react/jsx-runtime"], + "react/jsx-dev-runtime": ["node_modules/@types/react/jsx-dev-runtime"], + "react/*": ["node_modules/react/*"], + "lucide-react": ["node_modules/lucide-react"] + }, + "typeRoots": ["./node_modules/@types"] }, - "include": ["src"], + "include": ["src", "../../components/Mascot/**/*"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/app/ui_layer/browser/frontend/vite.config.ts b/app/ui_layer/browser/frontend/vite.config.ts index 003e6449e..2425c252b 100644 --- a/app/ui_layer/browser/frontend/vite.config.ts +++ b/app/ui_layer/browser/frontend/vite.config.ts @@ -7,6 +7,13 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@mascot': path.resolve(__dirname, '../../components/Mascot'), + // The Mascot module lives outside frontend/, so its imports of + // bare specifiers like 'react' can't be resolved by walking up + // from the file. Map them explicitly to the frontend's node_modules. + 'react': path.resolve(__dirname, 'node_modules/react'), + 'react-dom': path.resolve(__dirname, 'node_modules/react-dom'), + 'lucide-react': path.resolve(__dirname, 'node_modules/lucide-react'), }, }, build: { @@ -16,6 +23,11 @@ export default defineConfig({ server: { port: parseInt(process.env.VITE_PORT || '7925'), strictPort: true, + // The Mascot module lives in ../../components/Mascot (outside frontend/), + // so add the ui_layer root to the FS allowlist. + fs: { + allow: [path.resolve(__dirname, '../../')], + }, proxy: { '/ws': { target: `ws://localhost:${process.env.VITE_BACKEND_PORT || '7926'}`, diff --git a/app/ui_layer/components/Mascot/CraftBotMascot.tsx b/app/ui_layer/components/Mascot/CraftBotMascot.tsx new file mode 100644 index 000000000..b1dd83b4a --- /dev/null +++ b/app/ui_layer/components/Mascot/CraftBotMascot.tsx @@ -0,0 +1,333 @@ +import { useEffect, useRef, useState, type Ref } from 'react' +import { LEFT_EYE_D, RIGHT_EYE_D, BODY_D, FOREHEAD_D, ANTENNA_D } from './mascotPaths' +import { getPose } from './poses' +import type { MascotState } from './types' +import type { ReactionKind } from './mascotEngine' +import styles from './Mascot.module.css' + +interface Props { + state: MascotState + size?: number + /** Used by LivingUI creation: blush appears past 60% progress. */ + progress?: number + /** Incremented externally to trigger a celebratory wiggle. */ + completedCount?: number + /** Visual orientation. 'right' is the default (matches the original art); + * 'left' mirrors the head bump (with its antenna) over to the right side + * of the chest. The chest itself is symmetric around the mirror axis so + * the "white square" looks identical in both directions. */ + facing?: 'left' | 'right' + /** When non-null, renders one of the reaction visuals (body still stays + * pinned by the FSM). 'happy' = > < eyes + yellow ray burst; 'frustrated' + * = flat eye dashes + sweat drop. null = normal eye paths. */ + reaction?: ReactionKind | null + /** External handle to the that wraps the eyes. useCursorEyeTracking + * writes its `transform` attribute directly for the idle eye-following + * animation; the component must NOT bind that attribute in JSX or React + * will clobber the hook's writes on every render. */ + eyeGroupRef?: Ref +} + +// ───────────────────────────────────────────────────────────────────── +// Geometry & visual constants +// ───────────────────────────────────────────────────────────────────── + +// Single mirror pivot used for the entire SVG content. The body path's +// world X-range is 9–157 (chest spans -43→+105 in local coords plus the +// translate(52,…)), midpoint x=83. The chest portion of the silhouette is +// symmetric around that axis, so mirroring the full body path here leaves +// the "white square" visually unchanged while the head bump + forehead +// flip to the right side. Eyes and antenna ride along on the same pivot. +const MIRROR = 'translate(166 0) scale(-1 1)' + +// Happy-reaction "> <" bracket eyes. Positioned slightly below the default +// eye Y so the brackets sit at "smiling-cheek" height, not "eyebrow" height. +const HAPPY_EYE_Y = 115 +const HAPPY_EYE_HALF_SIZE = 8 +const HAPPY_EYE_LEFT_X = 82 +const HAPPY_EYE_RIGHT_X = 123.25 + +// Frustrated-reaction "— —" flat eye dashes. At the regular eye Y so the +// expression reads as flat-stare-into-the-void rather than smiling. +const FLAT_EYE_Y = 100 +const FLAT_EYE_HALF_WIDTH = 7 + +// 12 rays at 30° intervals — even starburst around the body center. +const HAPPY_RAY_COUNT = 12 + +// Theme color used for all eye variants (regular paths + brackets + flat +// dashes). One source of truth so the reactions read as the same character. +const EYE_COLOR = '#FF4D17' + +// ───────────────────────────────────────────────────────────────────── +// Sub-renderers — one per face variant + overlay +// ───────────────────────────────────────────────────────────────────── + +/** The "normal" eye paths: two filled SVG shapes from mascotPaths, classed + * with .eye (blinking) or .eyeClosed (sleeping) based on pose. */ +function RestingEyes({ closed }: { closed: boolean }) { + const eyeClass = closed ? styles.eyeClosed : styles.eye + return ( + <> + + + + + + + + ) +} + +/** Happy "> <" bracket eyes. Polylines because the original eye paths are + * solid fills and don't deform into brackets cleanly. Both eyes use the + * same color for visual unity during the reaction. */ +function HappyEyes() { + const leftPoints = + `${HAPPY_EYE_LEFT_X - HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y - HAPPY_EYE_HALF_SIZE} ` + + `${HAPPY_EYE_LEFT_X + HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y} ` + + `${HAPPY_EYE_LEFT_X - HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y + HAPPY_EYE_HALF_SIZE}` + const rightPoints = + `${HAPPY_EYE_RIGHT_X + HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y - HAPPY_EYE_HALF_SIZE} ` + + `${HAPPY_EYE_RIGHT_X - HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y} ` + + `${HAPPY_EYE_RIGHT_X + HAPPY_EYE_HALF_SIZE},${HAPPY_EYE_Y + HAPPY_EYE_HALF_SIZE}` + return ( + + + + + ) +} + +/** Frustrated "— —" flat eye dashes. Same fade-in animation as happy + * eyes so the swap from regular eyes doesn't read as a hard cut. */ +function FrustratedEyes() { + return ( + + + + + ) +} + +/** Pick the face variant for the current reaction state. The wrapping + * exists in every branch so the eye-tracking hook always has a + * stable element to write its transform onto, even across reaction + * swaps. We intentionally do NOT set a `transform` attribute here — + * the hook owns it; binding from React would clobber its writes. + * + * The `.eyeTrackingGroup` class adds a CSS `transition: transform` so + * the hook's setAttribute writes interpolate rather than snap. Same + * duration both for per-mousemove updates (light smoothing, feels like + * natural eye easing) and for the reset to center when tracking turns + * off — that reset is what would otherwise read as a jump. */ +function FaceEyes({ + reaction, + sleeping, + eyeGroupRef, +}: { + reaction: ReactionKind | null + sleeping: boolean + eyeGroupRef?: Ref +}) { + let inner + if (reaction === 'happy') inner = + else if (reaction === 'frustrated') inner = + else inner = + return {inner} +} + +/** Happy-reaction starburst. Lives outside the mirror group so it radiates + * symmetrically regardless of facing direction. The .breathe class sets + * overflow:visible so rays that fall outside the 160×200 viewBox still + * render. Each ray is a 16-unit dash positioned 82–98 units above local + * origin; the outer translate moves the origin to body center (80, 109), + * the inner rotate spins the dash around that center. */ +function HappyRays() { + return ( + + {Array.from({ length: HAPPY_RAY_COUNT }).map((_, i) => ( + + ))} + + ) +} + +/** Frustrated-reaction sweat drop — blue teardrop anchored next to the + * head. Lives OUTSIDE the mirror group so it stays on the same side of + * the panel regardless of facing direction (a side-swap would read as a + * glitch). The CSS animation handles the drip + fade. */ +function SweatDrop() { + return ( + + + + + ) +} + +/** Sleep Z's. Live OUTSIDE the mirror group so the 'z' letters don't + * render backwards when the mascot faces left — they're a stylistic + * cue, not anatomy. */ +function SleepZs() { + return ( + + z + + z + + + ) +} + +// ───────────────────────────────────────────────────────────────────── +// Wiggle trigger — fires the celebrate wiggle when the completed-count +// counter rises. Local-state hook factored out so the component body +// stays focused on render logic. +// ───────────────────────────────────────────────────────────────────── +function useWiggleOnIncrease(counter: number): boolean { + const [wiggling, setWiggling] = useState(false) + const prev = useRef(counter) + + useEffect(() => { + if (counter > prev.current) { + setWiggling(true) + const t = setTimeout(() => setWiggling(false), 600) + prev.current = counter + return () => clearTimeout(t) + } + prev.current = counter + }, [counter]) + + return wiggling +} + +// ───────────────────────────────────────────────────────────────────── +// Top-level component +// ───────────────────────────────────────────────────────────────────── + +export function CraftBotMascot({ + state, + size = 140, + progress = 0, + completedCount = 0, + facing = 'right', + reaction = null, + eyeGroupRef, +}: Props) { + const wiggling = useWiggleOnIncrease(completedCount) + const pose = getPose(state) + // Blush is pose-driven everywhere except LivingUI creation, where it's a + // late-stage progress signal (>60%). OR them so either path lights it up. + const showBlush = pose.showBlush || (state === 'creating' && progress > 60) + const breatheClass = pose.sleeping ? styles.sleepBreathe : styles.breathe + + return ( + + ) +} diff --git a/app/ui_layer/components/Mascot/Mascot.module.css b/app/ui_layer/components/Mascot/Mascot.module.css new file mode 100644 index 000000000..6786168e0 --- /dev/null +++ b/app/ui_layer/components/Mascot/Mascot.module.css @@ -0,0 +1,621 @@ +/* CraftBot Mascot — character + display panel styles. Lifted out of + LivingUIPage.module.css so the mascot is portable across screens. */ + +/* ───────────────────────────────────────────────────────────────────── + Character (CraftBotMascot.tsx) + ───────────────────────────────────────────────────────────────────── */ + +.wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.float { + display: inline-flex; + transform-origin: 50% 85%; +} + +.breathe { + transform-origin: 50% 60%; + animation: mascotBreathe 4s ease-in-out infinite; + /* Let happy-reaction rays + frustrated sweat drop extend past the + 160×200 viewBox bounds — rays sit at radius 82–98 from the body + center, sweat drop sits near the top corner. */ + overflow: visible; +} + +.sleepBreathe { + transform-origin: 50% 60%; + animation: mascotBreathe 6s ease-in-out infinite; + overflow: visible; +} + +.eye { + transform-origin: center; + transform-box: fill-box; + animation: mascotBlink 4.5s infinite; + /* When state flips and the animation is removed (→ .eyeClosed), the + transform interpolates from its computed value to the new static value + instead of snapping. The reverse direction restarts the animation so the + transition is mainly felt on close. */ + transition: transform 220ms ease; +} + +/* Eye-tracking wrapper . useCursorEyeTracking writes this element's + `transform` attribute directly (no React state), and a CSS transition + on `transform` smooths the discrete attribute writes — so both the + per-mousemove tracking updates AND the reset-to-center on disable + ease rather than snap. + 220ms matches the .eye / .eyeClosed transition so the whole eye + subsystem feels like one continuous interpolation. */ +.eyeTrackingGroup { + transition: transform 220ms ease-out; +} + +.eyeLeft { animation-delay: 0ms; } +.eyeRight { animation-delay: 120ms; } + +.eyeClosed { + transform-origin: center; + transform-box: fill-box; + transform: scaleY(0.08); + transition: transform 220ms ease; +} + +.blushPulse { + animation: mascotBlushPulse 2.2s ease-in-out infinite; +} + +.wiggle { + animation: mascotWiggle 600ms ease-in-out; +} + +/* Body silhouette is near-white — in light mode it blends into the bg, + so we stroke a thin outline. Dark mode needs no outline. */ +.body { + stroke: none; +} + +[data-theme="light"] .body { + stroke: var(--text-muted, var(--border-secondary)); + stroke-width: 3; + paint-order: stroke fill; +} + +/* Happy-reaction starburst. Each radiates outward from the body + center; the whole group bursts in, expands past the body silhouette, + and fades back out — sharp and snappy so it reads as a quick punch + of energy rather than a slow glow. transform-origin pins the scale + around the body center so the expansion radiates from the mascot. */ +.happyRays { + transform-origin: 80px 109px; + animation: mascotHappyRays 420ms ease-out forwards; + pointer-events: none; +} + +@keyframes mascotHappyRays { + 0% { opacity: 0; transform: scale(0.4); } + 15% { opacity: 1; transform: scale(0.92); } + 40% { opacity: 1; transform: scale(1.05); } + 100% { opacity: 0; transform: scale(1.35); } +} + +/* Bracket eyes ("> <") + flat dashes ("— —") shown during reactions. + The same fade-in masks the swap from the regular eye paths to the + reaction shape so it doesn't read as a hard cut. */ +.happyEyes { + animation: mascotHappyEyesFadeIn 140ms ease-out; +} + +@keyframes mascotHappyEyesFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Frustrated sweat drop — small blue teardrop next to the head. Pops + in, drips down a few pixels, then loops while the frustrated + reaction is held. Lives outside the mirror group so its position + stays anchored regardless of facing direction. */ +.sweatDrop { + animation: mascotSweatDrop 700ms ease-in-out; + pointer-events: none; +} + +@keyframes mascotSweatDrop { + 0% { opacity: 0; transform: translateY(-4px); } + 20% { opacity: 1; transform: translateY(0); } + 80% { opacity: 1; transform: translateY(6px); } + 100% { opacity: 0; transform: translateY(10px); } +} + +.sleepZ { + transform-origin: center; + transform-box: fill-box; + animation: mascotSleepZ 2.4s ease-in-out infinite; + opacity: 0; +} + +.sleepZDelayed { + animation-delay: 1.2s; +} + +@keyframes mascotBreathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.015); } +} + +@keyframes mascotBlink { + 0%, 92%, 100% { transform: scaleY(1); } + 95%, 97% { transform: scaleY(0.05); } +} + +@keyframes mascotBlushPulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 0.9; } +} + +@keyframes mascotWiggle { + 0% { transform: translateY(0) rotate(0deg); } + 20% { transform: translateY(-4px) rotate(-6deg); } + 45% { transform: translateY(-2px) rotate(6deg); } + 70% { transform: translateY(-1px) rotate(-3deg); } + 100% { transform: translateY(0) rotate(0deg); } +} + +@keyframes mascotSleepZ { + 0% { opacity: 0; transform: translateY(0); } + 30% { opacity: 1; } + 100% { opacity: 0; transform: translateY(-14px); } +} + +/* ───────────────────────────────────────────────────────────────────── + Display panel (MascotDisplay.tsx) + ───────────────────────────────────────────────────────────────────── */ + +.display { + display: flex; + flex-direction: column; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-primary); + flex-shrink: 0; +} + +/* The stage no longer hosts an action card — the speech bubble lives + inside the mascot wrapper itself — so this is just a positioned box + for the mascot to wander in. min-height needs to be generous enough + to give the bubble headroom above the mascot without clipping. */ +.stage { + position: relative; + padding: var(--space-3); + min-height: 220px; + overflow: hidden; +} + +/* ───────────────────────────────────────────────────────────────────── + Background scene (MascotBackground.tsx) + ───────────────────────────────────────────────────────────────────── + + Two cover-fit images stacked at the same position. The currently- + active theme reveals one and hides the other via opacity, so the + theme switch crossfades smoothly. Both stay loaded in the DOM, so + the swap has no flash-of-loading. + + Background sits at z-index:0 (mascotLayer is z-index:2) and is + pointer-events:none so it doesn't intercept the stage's wheel-to- + zoom handler or block clicks reaching the mascot. */ +.background { + /* ── Tunables ──────────────────────────────────────────────── + Change these three numbers to retune the scene; the math below + uses them via calc() so positioning stays consistent. + --bg-zoom baseline scale of the scene (higher = + more zoomed-in mountains/clouds, AND + wider safety margin against side gaps + at low user zoom). + --bg-image-grass-y the grass line's vertical position + inside the image file, as % of image + height from top. (Adjust per-image.) + --bg-ground-line-y where the ground should land in the + stage, as % of stage height. Should + match the mascot's body-bottom Y. */ + --bg-zoom: 4; + --bg-image-grass-y: 75%; + --bg-ground-line-y: 81%; + + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + overflow: hidden; + /* NO transform on .background itself. The previous version put + scale() here, which shrunk the WHOLE container (including the + image inside) below stage size at low user zoom — that's what + produced the black borders around the scene. The user-zoom + scale lives on the image elements (.bgDay/.bgNight) below + instead, so .background stays at stage-full size and only the + image content scales within it. */ +} + +.bgDay, +.bgNight { + position: absolute; + /* Image sized by HEIGHT ONLY — width follows the image's intrinsic + aspect ratio. This decouples image dimensions from panel width: + resizing the panel horizontally does NOT change image height, + so the grass line stays at a fixed Y in stage coords regardless + of panel resize (the "ground level invariant under aspect + change" rule). + --bg-zoom × 100% is the baseline scale (default 4× stage + height). The high baseline is what keeps the image wide enough + to fill the panel even at minimum user zoom (0.5×): natural + image width = aspect × 4 × stage_h ≈ 8 × stage_h, scaled by + 0.5 → ~4× stage_h, which covers up to max panel width 800px + with no side gaps. */ + height: calc(var(--bg-zoom) * 100%); + width: auto; + /* Vertical alignment math (all % of stage height): + grass_in_image_offset = bg-image-grass-y × bg-zoom + image_top = bg-ground-line-y − grass_in_image_offset + With defaults (75%, 4, 78%): + grass_in_image_offset = 75% × 4 = 300% + image_top = 78% − 300% = −222% + i.e., the image extends ~222% of stage height ABOVE the stage + so its 75%-into-image grass line lands at 78% of stage. */ + top: calc(var(--bg-ground-line-y) - var(--bg-image-grass-y) * var(--bg-zoom)); + bottom: auto; + left: 50%; + /* Combines horizontal centering (translateX(-50%) shifts the image + left by half its own width so its center sits at stage center) + with the user wheel-zoom scale. + transform-origin = (50%, --bg-image-grass-y) = the image's + horizontal center + the grass-line vertically. The horizontal + 50% maps to STAGE CENTER (since the image is centered there) — + and that's the same point the mascot scales around (see + .mascotLayer below). Both elements scale uniformly around stage + center, so the mascot's scene-relative position is preserved + across zoom: a feature at scene-offset B goes to s·B; the + mascot at wander-offset d goes to s·d; relative position + (d − B) stays constant in scene coordinates. */ + transform: translateX(-50%) scale(var(--mascot-zoom, 1)); + transform-origin: 50% var(--bg-image-grass-y); + transition: opacity 400ms ease, transform 120ms ease-out; +} + +/* Default (dark theme — the project's base palette) shows night. */ +.bgDay { opacity: 0; } +.bgNight { opacity: 1; } + +/* Light theme — swap which layer is visible. */ +[data-theme="light"] .bgDay { opacity: 1; } +[data-theme="light"] .bgNight { opacity: 0; } + +/* Wrapper around the mascot SVG. Always centered horizontally now — + the speech-bubble approach removes the old left-pin / right-card + layout. Top:62% lifts the mascot slightly above stage mid-bottom — + still close enough to the floor that the hop's squash-and-stretch + reads as "landing on a surface", but with more headroom above for + the speech bubble. */ +.mascotLayer { + position: absolute; + top: 65%; + left: 50%; + /* User wheel-zoom scale lives on .mascotLayer (NOT on .zoomLayer + deeper inside) so the scale composes with .wander's WAAPI hop + translate(d): mascot at wander offset d visually lands at offset + s·d from stage center, matching how the background's features at + scene-offset B land at s·B. Both scale uniformly around stage + center → mascot's scene-relative position stays invariant during + zoom. + transform-origin: 50% 95% + X = 50% (mascotLayer's local center; combined with left:50% + + translateX(-50%) above, this point lands at STAGE CENTER — + same horizontal pivot as the background image's scale.) + Y = 95% (the mascot's body-bottom in mascotLayer local — feet + stay at the same screen Y as the user zooms). */ + transform: translateX(-50%) translateY(-50%) scale(var(--mascot-zoom, 1)); + transform-origin: 50% 95%; + transition: transform 120ms ease-out; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +/* Wander motion is driven by the Web Animations API — the imperative + animate() call owns the transform property over a multi-keyframe hop + sequence (crouch → push off → arc → squash on landing → settle). + transform-origin is pinned near the feet so squash-and-stretch + deforms upward into the body instead of sinking through the floor. + pointer-events: auto overrides .mascotLayer's "none" so clicks reach + the wander wrapper. position:relative anchors the absolutely- + positioned speech bubble inside this same coordinate space. */ +.wander { + position: relative; + transform-origin: 50% 95%; + cursor: pointer; + pointer-events: auto; +} + +/* .zoomLayer is now a passive wrapper — kept for the JSX structure + (it groups CraftBotMascot + SpeechBubble inside .wander) and for + the bubble's `position: absolute` anchor, but no longer carries + the user-zoom scale. Scaling was moved UP to .mascotLayer so the + wander's WAAPI hop translate(d) gets scaled too — making the + mascot's wander offset behave uniformly with the background's + scaling and keeping the mascot's scene-relative position + invariant during zoom. */ +.zoomLayer { + position: relative; +} + +.wander:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; + border-radius: var(--radius-md); +} + +/* ───────────────────────────────────────────────────────────────────── + Speech bubble (SpeechBubble.tsx) + ───────────────────────────────────────────────────────────────────── + + Anchored to the right of the mascot — sits beside the body rather + than above it, so the stage doesn't need extra vertical headroom + for the bubble. Living inside .wander means the bubble inherits the + hop's translateX (follows the mascot across the stage) and the + small Y-bob on each landing. + + max-height clamps long results/messages so the bubble never grows + taller than the mascot itself; the body uses webkit-line-clamp to + add an ellipsis ("…") on the truncation line. + + pointer-events:none keeps the bubble click-through so it never + blocks clicks on the mascot underneath. */ + +.speechBubble { + position: absolute; + /* BOTTOM-anchored — the bubble grows UPWARD as its content gets + bigger (more lines, larger font, longer label). The tail is also + bottom-anchored (see ::before/::after rules below at `bottom: 14px`), + and the tail's CENTER sits ≈25 px above the bubble's bottom edge + (14 px bottom inset + 11 px half-triangle-height). So the bubble's + bottom edge has to sit 25 px BELOW the desired tail Y to put the + tail at the mascot's face. `bottom: 28%` lands the bubble's bottom + edge near the mascot's mid-body and the tail at ~33% of zoomLayer + height (mascot's forehead/eye region) — which is where the tail + used to point before the bottom-anchor change. Decrease this value + to move the bubble + tail DOWN; increase to move them UP. NOTE: + this value is paired with the tail pseudo-elements' `bottom` + values below — if you shift the bubble up by N% of zoomLayer + here, decrease the tail's `bottom: 14px / 15px` by the same N% of + zoomLayer (in px) to keep the tail's screen Y unchanged. */ + bottom: 25%; + top: auto; + /* Background + border match the agent chat bubble in ChatPage.module.css + (.message.agent) so the mascot's speech and the chat panel's agent + messages read as the same voice/style. Layout is not shared. */ + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md, 10px); + padding: var(--space-2, 8px) var(--space-3, 12px); + min-width: 140px; + max-width: 280px; + width: max-content; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + color: var(--text-primary); + font-size: var(--text-sm, 14px); + line-height: 1.45; + pointer-events: none; + /* IMPORTANT: no overflow:hidden / outer max-height on the bubble + itself. The body element handles its own truncation via + -webkit-line-clamp below — clipping at the bubble level would crop + the tail pseudo-elements (positioned outside the box) AND would + eat the bottom padding when the clamped body fills the height. */ + z-index: 4; + animation: speechBubbleIn 220ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* Side variants. Driven by useMascotBehavior's bubbleSide output so the + bubble flips to the opposite side when the mascot drifts toward an + edge — that keeps it from getting clipped against the stage walls. + Each variant sets its own horizontal anchor + transform-origin + tail + direction so the swap is self-contained. */ + +/* Side variants. transform-origin: bottom — so the entrance + scale-in animation grows the bubble OUT from the tail corner + (bottom-left for right-anchored, bottom-right for left-anchored), + matching the bottom-anchored growth direction. */ +.bubbleSideRight { + left: calc(100% + 10px); + transform-origin: left bottom; +} + +.bubbleSideLeft { + right: calc(100% + 10px); + transform-origin: right bottom; +} + +/* Bubble tails — small triangles joining the bubble to the mascot's + silhouette. Two stacked pseudo-elements (border trick): the outer + triangle is the bubble border, the inner is the fill stacked on top + so the join reads as a single tail. The two variants point in + opposite directions to match the side anchor. + + Vertically the tails sit near the BUBBLE'S BOTTOM (not its center + as in earlier versions). That keeps the tail's screen Y fixed as + the bubble grows upward — only the top edge moves, the bottom (and + the tail attached to it) stays nailed in place. */ +.speechBubble::after, +.speechBubble::before { + content: ''; + position: absolute; + width: 0; + height: 0; + pointer-events: none; +} + +/* Right-anchored: tail on bubble's LEFT, pointing at the mascot on the + left. Sized at 11/10 so the 1px border outline shows around the fill, + making the tail read as a clear pointer rather than a faint notch. + + Bottom offsets are 1px apart (outer at 14, inner at 15) so the two + triangles' VERTICAL CENTERS align — without that offset they'd share + a bottom edge and the outer border would only peek out on one side + (top), giving the tail an asymmetric outline. */ +.bubbleSideRight::before { + left: -11px; + bottom: 6px; + border-top: 11px solid transparent; + border-bottom: 11px solid transparent; + border-right: 11px solid var(--border-primary); +} + +.bubbleSideRight::after { + left: -10px; + bottom: 6px; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-right: 10px solid var(--bg-tertiary); +} + +/* Left-anchored: tail on bubble's RIGHT, pointing at the mascot on + the right. Mirror of the above. */ +.bubbleSideLeft::before { + right: -11px; + bottom: 6px; + border-top: 11px solid transparent; + border-bottom: 11px solid transparent; + border-left: 11px solid var(--border-primary); +} + +.bubbleSideLeft::after { + right: -10px; + bottom: 6px; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 10px solid var(--bg-tertiary); +} + +/* Label row for action bubbles — status icon (Loader2 / Check / X / Ban + from lucide-react) sits inline with the text. Same accent color + across every status so the GLYPH carries the meaning, not the color + (user pref: icon hierarchy, not color hierarchy). */ +.speechBubbleLabel { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-sans, system-ui); + font-size: var(--text-sm, 14px); + font-weight: 600; + color: var(--color-primary, #FF4F18); + margin-bottom: 3px; + letter-spacing: 0.01em; +} + +/* Loader2 spin animation — drives the "running" status icon while the + action is in flight. Pure CSS so it costs nothing in JS. */ +.speechBubbleIconSpin { + animation: speechBubbleIconSpin 1s linear infinite; +} + +@keyframes speechBubbleIconSpin { + to { transform: rotate(360deg); } +} + +/* Body — default sans for natural language (status, counts, hostnames, + messages). Switches to mono via .speechBubbleBodyMono for code-like + content (paths, commands, regex patterns). */ +.speechBubbleBody { + color: var(--text-primary); + font-family: var(--font-sans, system-ui); + font-size: var(--text-sm, 14px); + word-break: break-word; + white-space: pre-wrap; + /* Multi-line truncation with ellipsis. -webkit-line-clamp on a + -webkit-box keeps the layout single-stop and avoids needing JS to + measure overflow. The em-based max-height is a fallback for any + env that doesn't support line-clamp (matches the line-clamp count + at 1.45 line-height). Bumped from 4 → 7 lines so the bigger + bubble actually has room for more content; the bubble grows + UPWARD into the new space (see .speechBubble's `bottom:` anchor). */ + display: -webkit-box; + -webkit-line-clamp: 7; + line-clamp: 7; + -webkit-box-orient: vertical; + overflow: hidden; + max-height: 10.2em; +} + +/* Thinking and waiting use the primary color directly (no label/body + split) and a slightly italic body so they read as a status cue + rather than a quoted action invocation. */ +/* Per-action override: switch the body to monospace when the formatter + sets `bodyMono: true` — used for paths, shell commands, regex + patterns, anything that reads as code. */ +.speechBubbleBodyMono { + font-family: var(--font-mono, ui-monospace, monospace); +} + +/* Thinking + waiting are status-only bubbles (no action attached). + Both share a flex layout so the icon sits inline with the text. + The line-clamp truncation is unset here — these messages are short + enough that they never need to be clamped, and unsetting lets the + flex display take precedence. */ +.bubble_thinking .speechBubbleBody, +.bubble_waiting .speechBubbleBody { + display: flex; + align-items: center; + gap: 6px; + font-style: italic; + -webkit-line-clamp: unset; + line-clamp: unset; + max-height: none; + overflow: visible; +} + +/* Thinking: orange spinner + orange text (matches action-bubble label + color, reads as part of the mascot's voice). */ +.bubble_thinking .speechBubbleBody { + color: var(--color-primary, #FF4F18); +} + +/* Waiting: blue Reply icon + white text. Different from the thinking + palette because waiting-for-user is a different KIND of status — + it's prompting the user, not narrating mascot internal state. */ +.bubble_waiting .speechBubbleBody { + color: #ffffff; +} + +.bubbleWaitingIcon { + color: #4FA8FF; + flex-shrink: 0; +} + +/* Animated dots after "Thinking". Scoped to the inner span (not the + body) so the dots sit immediately after the text without picking + up the flex container's 6px gap between items. */ +.bubble_thinking .speechBubbleBody > span::after { + content: ''; + display: inline-block; + animation: speechBubbleThinkingDots 1.4s steps(4, end) infinite; +} + +@keyframes speechBubbleThinkingDots { + 0% { content: ''; } + 25% { content: '.'; } + 50% { content: '..'; } + 75% { content: '...'; } + 100% { content: ''; } +} + +/* Entrance pop. The bubble is now bottom-anchored (no translateY + centering), so the keyframe applies plain scale only. transform- + origin lives on .bubbleSideRight / .bubbleSideLeft (left bottom / + right bottom respectively) so the scale grows OUT FROM the tail + corner, matching the visual direction the bubble expands into. */ +@keyframes speechBubbleIn { + from { opacity: 0; transform: scale(0.7); } + to { opacity: 1; transform: scale(1); } +} diff --git a/app/ui_layer/components/Mascot/MascotBackground.tsx b/app/ui_layer/components/Mascot/MascotBackground.tsx new file mode 100644 index 000000000..bfdbe0f23 --- /dev/null +++ b/app/ui_layer/components/Mascot/MascotBackground.tsx @@ -0,0 +1,21 @@ +import styles from './Mascot.module.css' + +// Two image layers (day + night) are always rendered. CSS toggles their +// opacity off [data-theme] on — pre-loading both means the theme +// switch is instant with a smooth crossfade, no flash-of-empty-background +// while a new image downloads. +const DAY_URL = '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/mascot-backgrounds/background_1_day.jpg' +const NIGHT_URL = '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/mascot-backgrounds/background_1_night.jpg' + +/** Decorative scene behind the mascot. Light theme shows the day image, + * dark theme shows the night image. Sits at z-index 0 (mascotLayer is + * z-index 2) and is pointer-events:none so it doesn't block the + * stage's wheel-to-zoom handler or mascot clicks. */ +export function MascotBackground() { + return ( + + ) +} diff --git a/app/ui_layer/components/Mascot/MascotDisplay.tsx b/app/ui_layer/components/Mascot/MascotDisplay.tsx new file mode 100644 index 000000000..89f51283e --- /dev/null +++ b/app/ui_layer/components/Mascot/MascotDisplay.tsx @@ -0,0 +1,141 @@ +import { useRef, type CSSProperties } from 'react' +import { CraftBotMascot } from './CraftBotMascot' +import { MascotBackground } from './MascotBackground' +import { SpeechBubble } from './SpeechBubble' +import { useMascotState } from './useMascotState' +import { useMascotNarration } from './useMascotNarration' +import { useStageMeasure } from './useStageMeasure' +import { useMascotBehavior } from './useMascotBehavior' +import { useCursorEyeTracking } from './useCursorEyeTracking' +import { useWheelZoom } from './useWheelZoom' +import { computeMaxAmplitude } from './mascotEngine' +import styles from './Mascot.module.css' + +interface Props { + /** Optional pixel size for the mascot SVG. Defaults to 120. */ + mascotSize?: number +} + +// Zoom configuration for the stage's scroll-to-zoom interaction. +// - max=1 caps zoom-in at the design baseline (user can only scroll +// to shrink, not enlarge past the intended scene scale). +// - initial < max so the scene starts slightly pulled-back, giving +// the mascot some breathing room against the background on first +// paint. The user can still wheel up to max=1 for the baseline view. +const STAGE_ZOOM = { + min: 0.40, + max: 1, + step: 0.1, + initial: 0.8, +} as const + +export function MascotDisplay({ mascotSize = 80 }: Props) { + const { + state, + completedCount, + successTaskCount, + abortedTaskCount, + resetIdleTimer, + } = useMascotState() + const { bubble } = useMascotNarration({ mascotState: state }) + + // Sleeping states (idle = 30-min idle, stopped/error = external). + // Only 'idle' is recoverable by clicking; the others stay sleeping. + const isSleeping = state === 'idle' || state === 'stopped' || state === 'error' + const canBeWoken = state === 'idle' + + // ── Stage measurement → wander amplitude ─────────────────────────── + const stageRef = useRef(null) + const stageContentWidth = useStageMeasure(stageRef) + const maxAmplitude = computeMaxAmplitude(stageContentWidth, mascotSize) + + // ── Stage scroll-to-zoom ─────────────────────────────────────────── + // useWheelZoom binds the wheel listener (non-passive) and clamps the + // returned zoom value to STAGE_ZOOM's bounds. Multiplicative stepping + // and bound enforcement live inside the hook. + const zoom = useWheelZoom(stageRef, STAGE_ZOOM) + + // ── Behavior FSM ─────────────────────────────────────────────────── + // The mascot is free to wander any time the agent isn't sleeping; + // clicks are always allowed (sleeping mascots accept clicks so the + // user can wake them). `isWaiting` flips the FSM into the in-place + // jump phase whenever the agent reports it's waiting on a user reply. + const { wanderRef, facing, reaction, bubbleSide, isEyeTracking, handleClick } = useMascotBehavior({ + isActive: !isSleeping, + isClickable: true, + isAsleep: canBeWoken, + maxAmplitude, + onWakeFromSleep: resetIdleTimer, + successTaskCount, + abortedTaskCount, + isWaiting: state === 'waiting', + }) + + // ── Cursor eye tracking ──────────────────────────────────────────── + // The hook writes the eye group's transform attribute directly (no + // React state churn). It activates only during the 'track' idle + // mode the FSM cycles into periodically — the rest of the time the + // eyes do their normal blink/closed animations. + const eyeGroupRef = useRef(null) + useCursorEyeTracking(wanderRef, eyeGroupRef, { + enabled: isEyeTracking, + facing, + }) + + return ( +
+
+ {/* Decorative scene behind the mascot. z-index:0 (mascotLayer + is z-index:2) + pointer-events:none so it doesn't interfere + with clicks or the wheel-to-zoom handler on the stage. */} + +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + > + {/* Zoom wrapper inside .wander so the scale's pivot is the + mascot's current visual center — wander's hop translateX + has already shifted the wrapper to the mascot's live + position, and scaling that wrapper keeps the mascot in + place rather than drifting toward stage center. + The scale itself is applied via CSS reading the shared + --mascot-zoom variable on .stage. */} +
+ + +
+
+
+
+
+ ) +} diff --git a/app/ui_layer/components/Mascot/SpeechBubble.tsx b/app/ui_layer/components/Mascot/SpeechBubble.tsx new file mode 100644 index 000000000..8c08c8ec8 --- /dev/null +++ b/app/ui_layer/components/Mascot/SpeechBubble.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { Ban, Check, Loader2, MessageCircle, X } from 'lucide-react' +import type { MascotActionStatus, NarrationContent } from './useMascotNarration' +import styles from './Mascot.module.css' + +interface Props { + /** What to render inside the bubble. null hides the bubble entirely + * (no DOM presence so the layout is clean when nothing's playing). */ + content: NarrationContent | null + /** Which side of the mascot to anchor to. Driven by useMascotBehavior + * so the bubble sits opposite the mascot's drift direction and stays + * visible when the mascot wanders near a stage edge. */ + side?: 'left' | 'right' +} + +/** Floating speech bubble beside the mascot. Renders one of four + * content shapes: + * + * - `action` — formatted by an action-specific formatter from + * mascotFormatters.ts. Two-line: icon + label + body. + * - `message` — plain user-facing message text (send_message family). + * - `thinking` — between-actions placeholder while a task is still active. + * - `waiting` — agent is paused on a user reply (mascot-state override). */ +export function SpeechBubble({ content, side = 'right' }: Props) { + // Keep the last non-null content around for one fade-out cycle so the + // bubble doesn't pop out instantly when narration ends. In practice + // the FSM nearly always replaces with the next bubble anyway. + const [render, setRender] = useState(content) + + useEffect(() => { + if (content) setRender(content) + else { + const id = window.setTimeout(() => setRender(null), 180) + return () => window.clearTimeout(id) + } + }, [content]) + + if (!render) return null + + const sideClass = side === 'left' ? styles.bubbleSideLeft : styles.bubbleSideRight + // Re-mount key — when the bubble's visible content meaningfully + // changes, React unmounts the old node and mounts a new one (fresh + // fade-in). For action bubbles, the key includes the status icon so + // running→completed feels like a beat change. + const swapKey = bubbleSwapKey(render) + + return ( +
+ {renderContent(render)} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Per-kind rendering +// ───────────────────────────────────────────────────────────────────── + +function renderContent(content: NarrationContent): ReactNode { + switch (content.kind) { + case 'action': { + const { status, label, body, bodyMono } = content.format + return ( + <> +
+ + {label} +
+ {body && ( +
+ {body} +
+ )} + + ) + } + case 'message': + // No status icon — the message text is its own UI element. Body + // takes the full bubble; label slot is unused. + return
{content.text}
+ case 'thinking': + // Spinner + italic "Thinking…" — the dots are appended by the CSS + // dot-cycle animation on the inner span. Span wrapper is what + // lets the dots sit flush against the text (no flex-gap between). + return ( +
+ + Thinking +
+ ) + case 'waiting': + // MessageCircle is the SAME icon the Tasks panel's StatusIndicator + // shows for waiting tasks — keeps the visual language consistent + // across the two surfaces. Icon is forced to blue via + // .bubbleWaitingIcon; the text is white via the parent class. + return ( +
+ + Waiting for your reply… +
+ ) + } +} + +/** Status icon for the 'action' bubble kind. Same accent color across + * every status — only the GLYPH differentiates (user wanted icon + * hierarchy, not color hierarchy). Loader2 is CSS-animated to spin + * while running. */ +function StatusIcon({ status }: { status: MascotActionStatus }) { + switch (status) { + case 'running': + return + case 'completed': + return + case 'error': + return + case 'cancelled': + return + } +} + +/** Build the React `key` used to remount the bubble on meaningful + * content change — change in kind, status, or primary text triggers + * the fade-in animation so each new "beat" reads as a fresh utterance. */ +function bubbleSwapKey(content: NarrationContent): string { + switch (content.kind) { + case 'action': + return `action:${content.format.status}:${content.format.label}:${content.format.body ?? ''}` + case 'message': + return `message:${content.text.slice(0, 32)}` + case 'thinking': + return 'thinking' + case 'waiting': + return 'waiting' + } +} diff --git a/app/ui_layer/components/Mascot/index.ts b/app/ui_layer/components/Mascot/index.ts new file mode 100644 index 000000000..844fe8d52 --- /dev/null +++ b/app/ui_layer/components/Mascot/index.ts @@ -0,0 +1,17 @@ +export { CraftBotMascot } from './CraftBotMascot' +export { MascotBackground } from './MascotBackground' +export { MascotDisplay } from './MascotDisplay' +export { SpeechBubble } from './SpeechBubble' +export { useMascotState } from './useMascotState' +export { useMascotNarration } from './useMascotNarration' +export { useStageMeasure } from './useStageMeasure' +export { useMascotBehavior } from './useMascotBehavior' +export { useCursorEyeTracking } from './useCursorEyeTracking' +export { useWheelZoom } from './useWheelZoom' +export { POSES, getPose } from './poses' +export { transition as mascotTransition, INITIAL_STATE as MASCOT_INITIAL_STATE } from './mascotEngine' +export type { MascotState, MascotPose } from './types' +export type { MascotStateSnapshot } from './useMascotState' +export type { BehaviorInputs, BehaviorOutputs } from './useMascotBehavior' +export type { NarrationContent } from './useMascotNarration' +export type { Phase, EngineState, EngineEvent, ReactionKind, IdleMode } from './mascotEngine' diff --git a/app/ui_layer/components/Mascot/mascotEngine.ts b/app/ui_layer/components/Mascot/mascotEngine.ts new file mode 100644 index 000000000..8815a9099 --- /dev/null +++ b/app/ui_layer/components/Mascot/mascotEngine.ts @@ -0,0 +1,443 @@ +// Pure behavior layer for the mascot — no React, no DOM access except for +// readCurrentTranslateX (which is a tiny inspection helper). All the +// timing, geometry, randomization, and state-transition logic that the +// React hooks need lives here, so the React side is just plumbing. +// +// The mascot's behavior is modeled as a finite state machine. A single +// `transition(state, event)` function describes every move; the React +// hook in useMascotBehavior is responsible for firing the events at the +// right time (timers, animation onfinish callbacks, click handlers) and +// applying the side effects each phase implies (start an animation, snap +// inline style, etc.). + +// ───────────────────────────────────────────────────────────────────── +// Timing constants (milliseconds) +// ───────────────────────────────────────────────────────────────────── + +/** How long one hop animation runs (crouch → arc → land → settle). */ +export const HOP_DURATION_MS = 850 + +/** How long the "settle back to center" animation runs when the mascot + * is being pinned / silenced (e.g. an action card arrives). */ +export const SETTLE_DURATION_MS = 400 + +/** Rest between hops, sampled uniformly in this range. Tuned so the + * cumulative cycle (hop + rest) matches the original 1300–5500ms feel: + * hop is 850ms, so a 450–4650ms rest keeps total cycle 1300–5500ms. */ +export const POST_HOP_REST_MIN_MS = 450 +export const POST_HOP_REST_MAX_MS = 4650 + +/** Short delay between "mascot becomes active again" (waking from sleep, + * reaction ending) and the first new hop. Keeps the mascot from feeling + * sluggish on transitions but doesn't fire instantly. */ +export const WANDER_WARMUP_MS = 300 + +/** How long the happy reaction holds — the body stays still at the + * click position, > < eyes show, rays burst (rays have their own + * shorter CSS animation, ~420ms). */ +export const HAPPY_DURATION_MS = 700 + +/** A single vertical jump-in-place takes this long. Matched to the hop + * duration so the squash-and-stretch beats land on the same rhythm — + * the in-place jump should read as "same character, just vertical". */ +export const JUMP_IN_PLACE_DURATION_MS = HOP_DURATION_MS + +/** Rest between consecutive in-place jumps, sampled uniformly in this + * range. Shorter than the wander rest because the agent is "expectant" + * — repeated little hops feel more attentive than long pauses. */ +export const JUMP_REST_MIN_MS = 250 +export const JUMP_REST_MAX_MS = 500 + +/** Idle-mode cycle interval. The mascot switches between wandering + * (hopping around the stage) and tracking (standing still with eyes + * following the cursor) on this rhythm — randomized so it doesn't + * feel mechanical. */ +export const IDLE_CYCLE_MIN_MS = 6_000 +export const IDLE_CYCLE_MAX_MS = 10_000 + +// ───────────────────────────────────────────────────────────────────── +// Geometry constants +// ───────────────────────────────────────────────────────────────────── + +/** Matches var(--space-3) in Mascot.module.css — used to compute the + * mascot's available wander range from the stage's outer width. */ +export const STAGE_EDGE_PADDING_PX = 12 + +/** Per-hop step size limits. Caps the maximum DISTANCE of a single hop so + * the mascot never looks like it's teleporting — only the cumulative + * path covers the stage's full range. */ +export const MIN_HOP_STEP_PX = 20 +export const MAX_HOP_STEP_PX = 80 + +// ───────────────────────────────────────────────────────────────────── +// Action name handling +// ───────────────────────────────────────────────────────────────────── + +/** Action names sometimes arrive with spaces or hyphens (skill metadata) + * instead of snake_case. Normalize before matching against the sets + * below — same transform getActionRenderer uses for its registry. */ +export function normalizeActionName(name: string): string { + return name.toLowerCase().replace(/[\s-]+/g, '_') +} + +/** Action names that route through the narration FSM's 'message' phase + * (single-bubble display of the message body itself) rather than the + * default 'running' → 'result' lane. */ +export const MESSAGE_ACTIONS: ReadonlySet = new Set([ + 'send_message', + 'send_message_with_attachment', +]) + +// ───────────────────────────────────────────────────────────────────── +// DOM-inspection helpers +// ───────────────────────────────────────────────────────────────────── + +/** Reads the wander element's current *visual* X position — meaning the + * point on screen where the element's origin is right now. + * + * This is more than just reading the matrix's translateX. The hop + * keyframes use `translate(...) scale(...)`, and the element has + * `transform-origin: 50% 95%` (anchored near the feet). A scale around + * a non-(0,0) origin visually shifts the element by + * `origin_x * (1 - scale_x)`. So a mid-hop matrix like + * `matrix(0.94, 0, 0, 1.06, 20, -18)` (peak of the arc) has tx=20 but + * the body is actually drawn at `20 + 60*(1-0.94) = 23.6px` in viewport + * X. Reading just `tx` and snapping the inline style to `translate(20, + * …)` would teleport the body by ~3.6px sideways. We need the effective + * visual position so the click handler can pin the body exactly where + * the user saw it. + */ +export function readCurrentTranslateX(el: HTMLElement): number { + const style = window.getComputedStyle(el) + const t = style.transform + if (!t || t === 'none') return 0 + + let scaleX = 1 + let translateX = 0 + + const m2 = t.match(/matrix\(([^)]+)\)/) + if (m2) { + const v = m2[1].split(',').map(s => parseFloat(s.trim())) + if (Number.isFinite(v[0])) scaleX = v[0] + if (Number.isFinite(v[4])) translateX = v[4] + } else { + const m3 = t.match(/matrix3d\(([^)]+)\)/) + if (m3) { + const v = m3[1].split(',').map(s => parseFloat(s.trim())) + if (Number.isFinite(v[0])) scaleX = v[0] + if (Number.isFinite(v[12])) translateX = v[12] + } + } + + // transformOrigin's computed value is already resolved to pixels + // ("60px 114px" for 50% 95% on a 120px-wide element). + const ox = parseFloat(style.transformOrigin.split(/\s+/)[0] ?? '0') || 0 + + return translateX + ox * (1 - scaleX) +} + +// ───────────────────────────────────────────────────────────────────── +// Geometry helpers +// ───────────────────────────────────────────────────────────────────── + +/** Maximum amount the mascot's center can drift left/right of the stage + * midpoint before its edge crosses the stage's content area. */ +export function computeMaxAmplitude(stageContentWidth: number, effectiveSize: number): number { + return Math.max(0, (stageContentWidth - effectiveSize) / 2) +} + +/** Pick a uniform-random rest delay in [POST_HOP_REST_MIN_MS, POST_HOP_REST_MAX_MS]. */ +export function pickPostHopRest(): number { + return POST_HOP_REST_MIN_MS + Math.random() * (POST_HOP_REST_MAX_MS - POST_HOP_REST_MIN_MS) +} + +/** Pick the next random hop target. Constrains the step DISTANCE (not + * position), so each hop is a believable little leap regardless of + * panel width. Retries with random direction so the mascot naturally + * drifts away from the stage edges without explicit edge logic. */ +export function pickRandomHopTarget(prev: number, maxAmp: number): number { + if (maxAmp <= 0) return 0 + + const minStep = Math.min(MIN_HOP_STEP_PX, maxAmp) + const maxStep = Math.min(MAX_HOP_STEP_PX, maxAmp * 2) + if (maxStep <= minStep) { + // Stage is too tight to randomize meaningfully — just bounce to the + // other side of the current position, clamped to range. + return -Math.sign(prev || 1) * Math.min(maxAmp, minStep) + } + + for (let i = 0; i < 8; i++) { + const step = minStep + Math.random() * (maxStep - minStep) + const dir = Math.random() < 0.5 ? -1 : 1 + const candidate = prev + step * dir + if (candidate >= -maxAmp && candidate <= maxAmp) return candidate + } + // After repeated misses (near an edge), force a step toward the center. + return prev > 0 + ? Math.max(-maxAmp, prev - maxStep) + : Math.min(maxAmp, prev + maxStep) +} + +// ───────────────────────────────────────────────────────────────────── +// Animation keyframe builders +// ───────────────────────────────────────────────────────────────────── + +/** Build the WAAPI keyframe sequence for a hop. Eight keyframes: + * rest → crouch → push off → peak → descent → land (heavy squash) → + * recover → settle. Arc height scales with hop distance so short hops + * have low arcs and tall hops have proper-looking ones. */ +export function buildHopKeyframes(start: number, target: number): Keyframe[] { + const distance = target - start + const mid = start + distance / 2 + const arcH = Math.min(22, Math.max(8, Math.abs(distance) * 0.28)) + const arcMid = arcH * 0.65 + + return [ + { transform: `translate(${start}px, 0) scale(1, 1)`, offset: 0 }, + // Crouch — wider + shorter, slight dip into the floor. + { transform: `translate(${start}px, 4px) scale(1.15, 0.85)`, offset: 0.18 }, + // Push off — body stretches up as it leaves the ground. + { transform: `translate(${start + distance * 0.25}px, -${arcMid}px) scale(0.92, 1.10)`, offset: 0.35 }, + // Peak — apex of the arc. + { transform: `translate(${mid}px, -${arcH}px) scale(0.94, 1.06)`, offset: 0.5 }, + // Descent — still stretched, approaching landing spot. + { transform: `translate(${start + distance * 0.75}px, -${arcMid}px) scale(0.95, 1.06)`, offset: 0.65 }, + // Landing — heaviest squash, absorbs the impact. + { transform: `translate(${target}px, 4px) scale(1.18, 0.82)`, offset: 0.80 }, + // Recovery — half-squash on the way back to neutral. + { transform: `translate(${target}px, 2px) scale(1.05, 0.96)`, offset: 0.90 }, + // Settle — back to rest at the new spot. + { transform: `translate(${target}px, 0) scale(1, 1)`, offset: 1 }, + ] +} + +/** Two-keyframe ease used for non-hop transitions (mascot being pinned + * to the center, or recovering from a mid-hop interruption). */ +export function buildSettleKeyframes(start: number, target: number): Keyframe[] { + return [ + { transform: `translate(${start}px, 0) scale(1, 1)` }, + { transform: `translate(${target}px, 0) scale(1, 1)` }, + ] +} + +/** Vertical jump-in-place keyframes. Mirrors buildHopKeyframes' full + * eight-beat structure (rest → crouch → push off → peak → descent → + * land → recover → settle) with translateX locked to 0 throughout, so + * the animation reads as the same character motion as a hop — just + * vertical. Skipping the push-off / descent / recovery beats (as an + * earlier 5-keyframe version did) makes the jump feel stiff and + * un-Pixar; keeping them all preserves the squash-and-stretch arc. */ +export function buildJumpInPlaceKeyframes(): Keyframe[] { + const arcH = 18 + const arcMid = arcH * 0.65 + + return [ + // Rest. + { transform: 'translate(0px, 0) scale(1, 1)', offset: 0 }, + // Crouch — wider + shorter, slight dip into the floor. + { transform: 'translate(0px, 4px) scale(1.15, 0.85)', offset: 0.18 }, + // Push off — body stretches up as feet leave the ground. + { transform: `translate(0px, ${-arcMid}px) scale(0.92, 1.10)`, offset: 0.35 }, + // Peak — apex of the arc. + { transform: `translate(0px, ${-arcH}px) scale(0.94, 1.06)`, offset: 0.5 }, + // Descent — still stretched on the way back down. + { transform: `translate(0px, ${-arcMid}px) scale(0.95, 1.06)`, offset: 0.65 }, + // Landing — heaviest squash, absorbs the impact. + { transform: 'translate(0px, 4px) scale(1.18, 0.82)', offset: 0.80 }, + // Recovery — half-squash on the way back to neutral. + { transform: 'translate(0px, 2px) scale(1.05, 0.96)', offset: 0.90 }, + // Settle. + { transform: 'translate(0px, 0) scale(1, 1)', offset: 1 }, + ] +} + +/** Sample a uniform-random rest delay in [JUMP_REST_MIN_MS, JUMP_REST_MAX_MS]. */ +export function pickJumpRest(): number { + return JUMP_REST_MIN_MS + Math.random() * (JUMP_REST_MAX_MS - JUMP_REST_MIN_MS) +} + +/** Sample a uniform-random idle-mode cycle interval. */ +export function pickIdleCycleDelay(): number { + return IDLE_CYCLE_MIN_MS + Math.random() * (IDLE_CYCLE_MAX_MS - IDLE_CYCLE_MIN_MS) +} + +// ───────────────────────────────────────────────────────────────────── +// State machine +// ───────────────────────────────────────────────────────────────────── + +/** What the mascot is currently doing. + * + * - `inactive`: not wandering for any external reason (sleeping/pinned/ + * collapsed). The mascot sits at center, body not animated. + * - `resting`: awake, between hops. Breathing animation only. The + * wander timer is counting down toward the next hop (wander mode + * only — track mode just sits and lets the eyes follow cursor). + * - `hopping`: a hop animation is in flight. + * - `reacting`: a click or external trigger pinned the body for a + * reaction (happy on click / task success; frustrated on task abort). + * - `waitingJump`: agent is waiting for a user reply — body is pinned + * at the stage center and loops vertical jumps in place. + */ +export type Phase = 'inactive' | 'resting' | 'hopping' | 'reacting' | 'waitingJump' + +/** Which reaction visual to render while phase === 'reacting'. + * - `happy`: > < eyes + yellow ray burst (click + task success). + * - `frustrated`: flat eye dashes + sweat drop (task aborted/error). */ +export type ReactionKind = 'happy' | 'frustrated' + +/** Cycles within the `resting` phase to keep idle behavior varied. + * - `wander`: existing behavior — pick a random hop target periodically. + * - `track`: stand still and let the eyes follow the cursor. + * Cycled on a randomized timer in useMascotBehavior. */ +export type IdleMode = 'wander' | 'track' + +export interface EngineState { + phase: Phase + /** Where the mascot's rest position is, in px translateX from center. */ + position: number + /** Where the current hop is heading (only meaningful in 'hopping'). */ + hopTarget: number + /** Visual orientation — set on hop start to match direction of travel. */ + facing: 'left' | 'right' + /** Which reaction is being played. Only meaningful while phase === 'reacting'. */ + reactionKind: ReactionKind + /** Current idle behavior. Determines whether 'resting' arms the wander + * timer or stays put for eye-tracking. */ + idleMode: IdleMode +} + +export const INITIAL_STATE: EngineState = { + phase: 'inactive', + position: 0, + hopTarget: 0, + facing: 'right', + reactionKind: 'happy', + idleMode: 'wander', +} + +export type EngineEvent = + /** External world says "wander is/isn't allowed right now" (driven by + * the agent's sleeping/pinned/collapsed status). */ + | { type: 'SET_ACTIVE', active: boolean } + /** Wander timer expired; pick a new target and start hopping. */ + | { type: 'WANDER_TICK', maxAmp: number } + /** The hop's WAAPI animation reached offset 1. */ + | { type: 'HOP_DONE' } + /** The reaction timer expired. */ + | { type: 'REACTION_DONE' } + /** User clicked the mascot. Carries the visual X at click time so + * the reacting phase can pin the body to the right spot. */ + | { type: 'CLICK', currentVisualX: number } + /** External trigger (task end success/aborted) — pins the body at the + * current rest position and plays the requested reaction kind. */ + | { type: 'EXTERNAL_REACT', kind: ReactionKind } + /** Cycle timer fired — flip between wander and track idle modes. + * Only meaningful in the active idle phases (resting/hopping); other + * phases ignore it. */ + | { type: 'CYCLE_IDLE_MODE' } + /** External signal that the agent has entered (active=true) or exited + * (active=false) the waiting-for-user-reply state. */ + | { type: 'SET_WAITING', active: boolean } + +export function transition(state: EngineState, event: EngineEvent): EngineState { + switch (event.type) { + case 'SET_ACTIVE': { + if (event.active) { + // Become active. If we were stuck inactive, drop into resting + // and let the wander loop start. Active phases pass through. + return state.phase === 'inactive' + ? { ...state, phase: 'resting' } + : state + } + // Become inactive — cancel any current activity and reset position. + // The React side animates the body back to center. + return { ...state, phase: 'inactive', position: 0, hopTarget: 0 } + } + + case 'WANDER_TICK': { + // Only the resting phase honors wander ticks. The wander effect + // already clears its timer on phase change, but the reducer guard + // protects against any leftover dispatch from a race. + if (state.phase !== 'resting') return state + const target = pickRandomHopTarget(state.position, event.maxAmp) + // Sub-pixel "movement" produces no animation but does flip facing; + // skip to avoid a no-op hop cycle. + if (Math.abs(target - state.position) < 1) return state + return { + ...state, + phase: 'hopping', + hopTarget: target, + facing: target > state.position ? 'right' : 'left', + } + } + + case 'HOP_DONE': { + if (state.phase !== 'hopping') return state + // Hop reached its target — that's now the resting position. + return { ...state, phase: 'resting', position: state.hopTarget } + } + + case 'REACTION_DONE': { + if (state.phase !== 'reacting') return state + return { ...state, phase: 'resting' } + } + + case 'CLICK': { + // Already reacting? Ignore — don't restart mid-reaction. + if (state.phase === 'reacting') return state + // From any other phase (including 'inactive' for the wake-from- + // sleep path), snap to the click X and react. Note: the external + // click handler is responsible for rejecting clicks when the + // mascot is pinned by an action card — the FSM accepts them here. + return { + ...state, + phase: 'reacting', + position: event.currentVisualX, + hopTarget: event.currentVisualX, + reactionKind: 'happy', + } + } + + case 'EXTERNAL_REACT': { + // Already reacting? Ignore — don't stack/override an in-flight + // reaction. Otherwise pin at the current rest position (no hop + // interruption snap because external reactions usually arrive + // outside of motion) and play the requested kind. + if (state.phase === 'reacting') return state + return { + ...state, + phase: 'reacting', + hopTarget: state.position, + reactionKind: event.kind, + } + } + + case 'CYCLE_IDLE_MODE': { + // Only flips during the active idle phases. Reacting/inactive/ + // waitingJump explicitly ignore — those phases own their own + // visuals and would look wrong if the eyes started tracking + // mid-reaction or mid-jump. The cycle effect re-arms when phase + // returns to resting, so a tick missed during e.g. a hop comes + // back on the next cycle. + if (state.phase !== 'resting' && state.phase !== 'hopping') return state + return { ...state, idleMode: state.idleMode === 'wander' ? 'track' : 'wander' } + } + + case 'SET_WAITING': { + if (event.active) { + // Enter waiting jump from anywhere except 'reacting' (don't + // interrupt an in-flight reaction — REACTION_DONE will land us + // back in 'resting' and the React-side re-sync effect will + // promote into waitingJump on the next render). Snap position + // to 0 because the jump animation runs in-place at the stage + // center. + if (state.phase === 'reacting') return state + return { ...state, phase: 'waitingJump', position: 0, hopTarget: 0 } + } + // Leaving waiting — drop back into the normal idle loop. The + // wander/track cycle picks up wherever idleMode left off. + if (state.phase !== 'waitingJump') return state + return { ...state, phase: 'resting' } + } + } +} diff --git a/app/ui_layer/components/Mascot/mascotPaths.ts b/app/ui_layer/components/Mascot/mascotPaths.ts new file mode 100644 index 000000000..303c45fde --- /dev/null +++ b/app/ui_layer/components/Mascot/mascotPaths.ts @@ -0,0 +1,18 @@ +// SVG path data for the CraftBot mascot. Lifted verbatim from the original +// logo (paths 2 and 3 for the eyes). Kept in their own module so the React +// component stays focused on animation orchestration. + +export const LEFT_EYE_D = + 'M0 0 C2.92937654 1.81310142 5.43647949 3.87295899 7 7 C7.12311126 9.56761586 7.18836415 12.10706019 7.203125 14.67578125 C7.20882507 15.43795456 7.21452515 16.20012787 7.22039795 16.98539734 C7.22985081 18.60019542 7.23638307 20.21501282 7.24023438 21.82983398 C7.24992133 24.29250092 7.28093303 26.75432656 7.3125 29.21679688 C7.3190321 30.78645338 7.32428176 32.35611586 7.328125 33.92578125 C7.3404718 34.65928299 7.3528186 35.39278473 7.36553955 36.14851379 C7.34154924 41.69538739 6.08230904 44.91769096 2.1875 48.8125 C-0.8527265 50.46290867 -2.57949832 50.54728027 -6 50 C-8.9228334 48.16899068 -11.43323854 46.13352292 -13 43 C-13.28424778 37.93535225 -13.31377183 32.86972252 -13.35009766 27.79785156 C-13.36663059 26.1063051 -13.39388581 24.41482773 -13.43212891 22.72363281 C-13.48710365 20.27893001 -13.50880304 17.835864 -13.5234375 15.390625 C-13.54610687 14.64041077 -13.56877625 13.89019653 -13.59213257 13.11724854 C-13.57625216 9.13973933 -13.06467198 6.59267156 -10.59228516 3.53808594 C-6.97219204 -0.00631866 -5.04757551 -0.58241256 0 0 Z' + +export const RIGHT_EYE_D = + 'M0 0 C4.33669495 0.36139125 5.72030822 1.28280822 8.75 4.3125 C11.19715759 7.98323639 11.07300278 10.80084797 11.0859375 15.05078125 C11.0925943 15.80916794 11.0992511 16.56755463 11.10610962 17.34892273 C11.11623038 18.95353778 11.12092682 20.55819486 11.12060547 22.1628418 C11.12496613 24.60602159 11.16125324 27.04737571 11.19921875 29.49023438 C11.20508977 31.05207718 11.20905726 32.61392854 11.2109375 34.17578125 C11.22530853 34.90098465 11.23967957 35.62618805 11.25448608 36.37336731 C11.20886626 41.49335244 9.99115623 44.38403683 6.75 48.3125 C3.0126144 50.8040904 1.17668417 50.77043285 -3.25 50.3125 C-6.00329021 48.51687595 -7.77627461 47.25995078 -9.25 44.3125 C-9.53474008 38.94460889 -9.56375869 33.57565738 -9.60009766 28.20092773 C-9.61665091 26.40554842 -9.6439329 24.61023484 -9.68212891 22.81518555 C-9.73696923 20.22409959 -9.75878148 17.63455732 -9.7734375 15.04296875 C-9.79610687 14.24384567 -9.81877625 13.4447226 -9.84213257 12.62138367 C-9.82575533 8.28890941 -9.65327311 6.79951266 -6.77978516 3.3293457 C-5.94495605 2.66378662 -5.11012695 1.99822754 -4.25 1.3125 C-3.25 0.3125 -3.25 0.3125 0 0 Z' + +export const BODY_D = + 'M0 0 C4.07948561 2.39448068 5.59075814 4.56296171 7 9 C7.22705078 12.13793945 7.22705078 12.13793945 7.1953125 15.61328125 C7.18886719 16.86044922 7.18242188 18.10761719 7.17578125 19.39257812 C7.15902344 20.68615234 7.14226563 21.97972656 7.125 23.3125 C7.11597656 24.62541016 7.10695312 25.93832031 7.09765625 27.29101562 C7.07411797 30.52752913 7.0412352 33.76367149 7 37 C8.66797386 36.99880661 8.66797386 36.99880661 10.36964417 36.99758911 C20.86029267 36.99398337 31.35045173 37.02182328 41.84094334 37.0802784 C47.23400448 37.10932364 52.62663122 37.12737304 58.01977539 37.12011719 C63.22668836 37.11351937 68.43274326 37.13603529 73.63947868 37.17855453 C75.62370689 37.18967761 77.60802221 37.19039313 79.59225655 37.18042374 C82.37698491 37.16822818 85.15910331 37.19337188 87.94360352 37.22705078 C89.16820282 37.20871941 89.16820282 37.20871941 90.4175415 37.1900177 C95.52547827 37.30099637 98.99606725 38.37102114 102.70169735 42.00092697 C105.34443169 46.07010063 104.95122879 50.54408606 104.87402344 55.25024414 C104.88760391 56.93663246 104.88760391 56.93663246 104.90145874 58.65708923 C104.92204542 62.36727631 104.89196344 66.07508395 104.859375 69.78515625 C104.85901127 72.36535209 104.86101804 74.94554926 104.86529541 77.52574158 C104.86658985 82.93035323 104.84438547 88.33409224 104.80273438 93.73852539 C104.75493531 99.9804595 104.74970777 106.22131494 104.7676847 112.46338183 C104.78417608 118.4777967 104.77210042 124.49172482 104.74695587 130.50613213 C104.73858741 133.06141287 104.73710673 135.61672571 104.74260712 138.17201424 C104.74588605 141.74170904 104.71678767 145.30968886 104.67871094 148.87915039 C104.68569 149.93643341 104.69266907 150.99371643 104.69985962 152.08303833 C104.57445297 159.2743698 104.57445297 159.2743698 101.52008057 163.1325531 C100.68845398 163.74881058 99.85682739 164.36506805 99 165 C98.67 165.33 98.34 165.66 98 166 C96.26523893 166.10500529 94.52618895 166.13965476 92.78826904 166.14717102 C91.65820938 166.15432632 90.52814972 166.16148163 89.36384583 166.16885376 C88.11294662 166.17154266 86.86204742 166.17423157 85.57324219 166.17700195 C84.2643956 166.18357315 82.95554901 166.19014435 81.60704041 166.19691467 C78.00564524 166.21435117 74.40426337 166.22488407 70.80283642 166.2331202 C68.55363673 166.23826578 66.3044412 166.24434177 64.05524445 166.25063133 C57.02063452 166.2696926 49.98603514 166.28392055 42.95140433 166.2922433 C34.82660857 166.30201386 26.70208538 166.32832446 18.57738566 166.36874419 C12.30132843 166.39889697 6.02534935 166.41371145 -0.25077891 166.41702431 C-4.00079723 166.41937622 -7.75052467 166.42835395 -11.50046539 166.45348549 C-15.68570621 166.48107966 -19.8703381 166.47693768 -24.05566406 166.4699707 C-25.91986748 166.48918579 -25.91986748 166.48918579 -27.82173157 166.50878906 C-29.53374001 166.49837334 -29.53374001 166.49837334 -31.28033447 166.48774719 C-32.26996311 166.49013555 -33.25959175 166.4925239 -34.27920914 166.49498463 C-37.76816827 165.86024965 -39.45532783 164.41714537 -42 162 C-43.20253467 159.59493067 -43.13622583 158.07613035 -43.15390015 155.389328 C-43.16250061 154.4177626 -43.17110107 153.4461972 -43.17996216 152.44519043 C-43.18422211 151.37555054 -43.18848206 150.30591064 -43.19287109 149.20385742 C-43.20104858 148.08018265 -43.20922607 146.95650787 -43.21765137 145.79878235 C-43.2425332 142.0676833 -43.2590276 138.33662234 -43.2734375 134.60546875 C-43.27876118 133.33376642 -43.28408485 132.06206409 -43.28956985 130.75182533 C-43.31080435 125.40829984 -43.32993447 120.06478772 -43.34119225 114.72123241 C-43.35743088 107.0639262 -43.3906063 99.40712747 -43.44759309 91.75001258 C-43.49096487 85.71652717 -43.50693743 79.6832728 -43.51332474 73.64962196 C-43.5200545 71.08312682 -43.53532089 68.51663955 -43.55921555 65.95024681 C-43.5907278 62.36121138 -43.59092413 58.77346282 -43.58349609 55.18432617 C-43.59990143 54.12075897 -43.61630676 53.05719177 -43.63320923 51.96139526 C-43.57194702 44.7439133 -43.57194702 44.7439133 -40.58853149 40.86347961 C-37.01419613 38.29032139 -35.33942285 37.87735964 -30.9921875 37.90234375 C-30.00605469 37.90556641 -29.01992188 37.90878906 -28.00390625 37.91210938 C-26.97136719 37.92048828 -25.93882813 37.92886719 -24.875 37.9375 C-23.31458984 37.94426758 -23.31458984 37.94426758 -21.72265625 37.95117188 C-19.14838038 37.96298048 -16.57421854 37.97944925 -14 38 C-14.02094727 37.05680908 -14.04189453 36.11361816 -14.06347656 35.1418457 C-14.13181428 31.62872476 -14.18187747 28.11631415 -14.21972656 24.6027832 C-14.23979203 23.0847659 -14.26705134 21.56682541 -14.30175781 20.04907227 C-14.35050393 17.86194878 -14.37301805 15.67585048 -14.390625 13.48828125 C-14.41157227 12.17384033 -14.43251953 10.85939941 -14.45410156 9.50512695 C-13.92906843 5.45249214 -12.86169466 3.85337965 -10 1 C-6.79139471 -0.60430264 -3.4809543 -0.48105318 0 0 Z' + +export const FOREHEAD_D = + 'M0 0 C4.08091529 2.39531984 5.58538732 4.56420399 7 9 C7.24291992 12.10620117 7.24291992 12.10620117 7.23046875 15.54296875 C7.22853516 16.78111328 7.22660156 18.01925781 7.22460938 19.29492188 C7.20624023 21.22174805 7.20624023 21.22174805 7.1875 23.1875 C7.18685547 24.48365234 7.18621094 25.77980469 7.18554688 27.11523438 C7.14041085 36.71917831 7.14041085 36.71917831 6 39 C-0.6 39 -7.2 39 -14 39 C-14.09202984 34.39083893 -14.1715972 29.78488631 -14.21972656 25.17553711 C-14.23979558 23.60902077 -14.26705751 22.04257891 -14.30175781 20.47631836 C-14.35048754 18.21970206 -14.3730147 15.96407917 -14.390625 13.70703125 C-14.41127014 13.01100296 -14.43191528 12.31497467 -14.45318604 11.59785461 C-14.45483032 8.00418065 -14.08015358 6.12318719 -12.10058594 3.08081055 C-8.48911679 -0.49665942 -4.91351779 -0.67902741 0 0 Z' + +export const ANTENNA_D = + 'M0 0 C2.80703896 1.34040619 3.86054551 2.54745461 6 5 C7.20266683 8.96172602 7.47141915 10.96287786 5.75 14.75 C4.88375 15.86375 4.88375 15.86375 4 17 C3.566875 17.5775 3.13375 18.155 2.6875 18.75 C0.17427295 20.61164966 -1.90568597 20.81434116 -5 21 C-8.97317198 19.95940734 -11.6509186 18.39311757 -14 15 C-14.86292997 10.34540805 -14.65016836 7.04026938 -12.125 3 C-8.46623217 -0.44354619 -4.87578937 -0.78700125 0 0 Z' diff --git a/app/ui_layer/components/Mascot/narrationFormat.ts b/app/ui_layer/components/Mascot/narrationFormat.ts new file mode 100644 index 000000000..1e6bc316f --- /dev/null +++ b/app/ui_layer/components/Mascot/narrationFormat.ts @@ -0,0 +1,33 @@ +// Shared formatting helpers for the speech bubble's narration content. +// Most action-specific formatting now lives in mascotFormatters.ts (in +// the Tasks/actionRenderers folder, colocated with the per-action card +// renderers). This file keeps only the formatters that aren't tied to a +// specific action — currently just the send_message text extractor. + +import type { ActionItem } from '../../browser/frontend/src/types' + +/** Truncate `s` to at most `max` chars, appending an ellipsis on truncation. */ +function trim(s: string, max: number): string { + return s.length > max ? s.slice(0, max).trimEnd() + '…' : s +} + +/** Render any value to a string, capped at `max` chars. Strings pass through; + * everything else goes through JSON.stringify. nullish → empty string. */ +function stringifyValue(v: unknown, max = 100): string { + if (v == null) return '' + const s = typeof v === 'string' ? v : JSON.stringify(v) + return trim(s, max) +} + +/** Pull the message text out of a send_message action's input. Falls back + * to a stringified dump if no recognised field is present. */ +export function formatMessage(input: Record | null): string { + if (!input) return '' + const msg = input.message ?? input.text ?? input.content + if (typeof msg === 'string') return trim(msg, 220) + return stringifyValue(input, 220) +} + +// `ActionItem` re-export keeps consumers from needing to reach into the +// browser types directly; it's used by the narration hook for typing. +export type { ActionItem } diff --git a/app/ui_layer/components/Mascot/poses.ts b/app/ui_layer/components/Mascot/poses.ts new file mode 100644 index 000000000..66b2b14fe --- /dev/null +++ b/app/ui_layer/components/Mascot/poses.ts @@ -0,0 +1,25 @@ +import type { MascotPose, MascotState } from './types' + +// Each mascot state resolves to a pose descriptor that drives the SVG's +// animation classes. Keeping this as data (rather than a switch inside the +// component) makes it easy to add states + lets the CLI mascot, when it +// arrives, consume the same vocabulary without depending on React. + +export const POSES: Record = { + idle: { sleeping: true, showBlush: false }, + // 'resting' is the awake-but-quiet state used while the agent is idle + // but hasn't been idle long enough to enter the sleeping pose yet. + resting: { sleeping: false, showBlush: false }, + stopped: { sleeping: true, showBlush: false }, + thinking: { sleeping: false, showBlush: false }, + working: { sleeping: false, showBlush: false }, + creating: { sleeping: false, showBlush: false }, + launching: { sleeping: false, showBlush: false }, + waiting: { sleeping: false, showBlush: false }, + paused: { sleeping: false, showBlush: false }, + error: { sleeping: true, showBlush: false }, +} + +export function getPose(state: MascotState): MascotPose { + return POSES[state] +} diff --git a/app/ui_layer/components/Mascot/types.ts b/app/ui_layer/components/Mascot/types.ts new file mode 100644 index 000000000..64d719aea --- /dev/null +++ b/app/ui_layer/components/Mascot/types.ts @@ -0,0 +1,25 @@ +// Shared mascot vocabulary. Lives at the ui_layer level so a future CLI mascot +// can reuse the same state names even if it can't reuse the React component. + +export type MascotState = + // Generic agent states (used by the chat panel mascot display) + | 'idle' + // 'resting' is the holding state for the 30-minute pre-sleep window — + // the agent is idle but the mascot still looks awake. + | 'resting' + | 'thinking' + | 'working' + | 'waiting' + | 'paused' + | 'error' + // LivingUI lifecycle states (preserved from the original CraftBotPet) + | 'creating' + | 'launching' + | 'stopped' + +export interface MascotPose { + /** Sleeping silhouette + closed eyes + sleep Z's */ + sleeping: boolean + /** Cheek blush flanks the eyes (used during creation milestones) */ + showBlush: boolean +} diff --git a/app/ui_layer/components/Mascot/useCursorEyeTracking.ts b/app/ui_layer/components/Mascot/useCursorEyeTracking.ts new file mode 100644 index 000000000..14195f479 --- /dev/null +++ b/app/ui_layer/components/Mascot/useCursorEyeTracking.ts @@ -0,0 +1,84 @@ +import { useEffect, type RefObject } from 'react' + +// Eye-track range. Eyes shift at most ±MAX_OFFSET_X px horizontally and +// ±MAX_OFFSET_Y px vertically. The amount is mapped linearly from cursor +// distance, clamped at MAX_DIST (after which the eyes are pinned at the +// extreme — keeps the look believable when the cursor is far off-screen). +const MAX_OFFSET_X = 4 +const MAX_OFFSET_Y = 3 +const MAX_DIST = 250 + +interface Options { + /** When false, the hook detaches its listeners and resets the eye + * transform to identity. Driven by useMascotBehavior.isEyeTracking. */ + enabled: boolean + /** The mascot SVG's facing direction. When 'left', the eyes are + * rendered inside a mirrored , so the X axis is flipped — we + * invert the cursor-relative X to keep gaze tracking screen-correct. */ + facing: 'left' | 'right' +} + +/** Drives the eye-tracking idle animation: while enabled, the mascot's + * eyes shift toward the user's cursor position. + * + * Implementation note: this hook deliberately bypasses React state and + * writes the SVG `transform` attribute directly. Eye tracking fires on + * every mousemove (10s–100s of times per second) and routing each + * update through React would re-render the whole mascot subtree for a + * tiny visual delta. Direct DOM writes keep the cost at ~one + * setAttribute per mousemove with zero virtual-DOM churn. + * + * Trade-off: the JSX must NOT set its own `transform` attribute on the + * eyeGroup element — that would clobber the hook's writes on every + * React render. The component contract is "give me a ref to a bare g + * element and I'll own its transform". */ +export function useCursorEyeTracking( + mascotRef: RefObject, + eyeGroupRef: RefObject, + { enabled, facing }: Options, +): void { + useEffect(() => { + const reset = () => { + const g = eyeGroupRef.current + if (g) g.setAttribute('transform', 'translate(0 0)') + } + + if (!enabled) { + reset() + return + } + + // Initial reset — when tracking just turned on, start from center + // and let the first mousemove pull the eyes toward the cursor. The + // alternative (waiting for a mousemove before any visible change) + // can leave stale offsets from a previous tracking session. + reset() + + const update = (clientX: number, clientY: number) => { + const el = mascotRef.current + const g = eyeGroupRef.current + if (!el || !g) return + const rect = el.getBoundingClientRect() + const dx = clientX - (rect.left + rect.width / 2) + const dy = clientY - (rect.top + rect.height / 2) + const ox = clamp((dx / MAX_DIST) * MAX_OFFSET_X, -MAX_OFFSET_X, MAX_OFFSET_X) + const oy = clamp((dy / MAX_DIST) * MAX_OFFSET_Y, -MAX_OFFSET_Y, MAX_OFFSET_Y) + // Compensate for the SVG mirror group when facing left — that + // group flips child X, so we pre-flip our offset to cancel it + // out and keep the gaze tracking the cursor's actual screen X. + const finalX = facing === 'left' ? -ox : ox + g.setAttribute('transform', `translate(${finalX} ${oy})`) + } + + const onMove = (e: MouseEvent) => update(e.clientX, e.clientY) + window.addEventListener('mousemove', onMove, { passive: true }) + return () => { + window.removeEventListener('mousemove', onMove) + reset() + } + }, [enabled, facing, mascotRef, eyeGroupRef]) +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)) +} diff --git a/app/ui_layer/components/Mascot/useMascotBehavior.ts b/app/ui_layer/components/Mascot/useMascotBehavior.ts new file mode 100644 index 000000000..031f9091e --- /dev/null +++ b/app/ui_layer/components/Mascot/useMascotBehavior.ts @@ -0,0 +1,471 @@ +import { + useCallback, + useEffect, + useReducer, + useRef, + type RefObject, +} from 'react' +import { + HAPPY_DURATION_MS, + HOP_DURATION_MS, + INITIAL_STATE, + JUMP_IN_PLACE_DURATION_MS, + SETTLE_DURATION_MS, + WANDER_WARMUP_MS, + buildHopKeyframes, + buildJumpInPlaceKeyframes, + buildSettleKeyframes, + pickIdleCycleDelay, + pickJumpRest, + pickPostHopRest, + readCurrentTranslateX, + transition, + type EngineState, + type Phase, + type ReactionKind, +} from './mascotEngine' + +export interface BehaviorInputs { + /** Whether the mascot is free to wander. False when the agent is + * sleeping, an action card is pinned, or the panel is collapsed. */ + isActive: boolean + /** Click guard: when false (panel collapsed or pinned by an action), + * clicks on the mascot are ignored entirely. Distinct from `isActive` + * because a sleeping (asleep but not pinned) mascot should still wake + * on click. */ + isClickable: boolean + /** Whether the mascot is currently in its "needs to be woken" state. + * When true, a click also fires `onWakeFromSleep` before reacting. */ + isAsleep: boolean + /** Maximum X-amplitude the mascot's center can drift from stage + * midpoint (computed from stage width + mascot size). */ + maxAmplitude: number + /** External wake-up trigger — typically `resetIdleTimer` from + * useMascotState. Called on click when the mascot is asleep. */ + onWakeFromSleep: () => void + /** Monotonic counter — rises by 1 each time a task finishes successfully. + * Drives the celebrate (happy) external reaction. */ + successTaskCount?: number + /** Monotonic counter — rises by 1 each time a task is aborted (status + * cancelled or error). Drives the frustrated external reaction. */ + abortedTaskCount?: number + /** Whether the agent is waiting for a user reply. When true, the + * mascot is forced into the waitingJump phase (centers + jumps in + * place). Reverts to normal idle when this flips back to false. */ + isWaiting?: boolean +} + +export interface BehaviorOutputs { + /** Ref to attach to the element that gets the hop animations. */ + wanderRef: RefObject + /** Current FSM phase — drives renderer conditional logic. */ + phase: Phase + /** Visual orientation matching the last hop's direction of travel. */ + facing: 'left' | 'right' + /** Which reaction visual to render. null when phase !== 'reacting'. */ + reaction: ReactionKind | null + /** Which side of the mascot a peripheral overlay (speech bubble) should + * anchor to so it doesn't clip against the stage edges. Derived from + * the mascot's target X — bubble sits opposite the mascot's drift. */ + bubbleSide: 'left' | 'right' + /** True iff the FSM is in 'resting' phase with idleMode === 'track'. + * Drives the cursor eye-tracking hook on the consumer side. */ + isEyeTracking: boolean + /** Onclick callback for the mascot wrapper. */ + handleClick: () => void +} + +/** React integration layer for the mascot FSM. Owns: + * + * - The reducer state (`useReducer` over the pure transition function). + * - Refs to the latest input values (so timer callbacks see fresh data). + * - One effect per phase that applies the corresponding side effect + * (wander timer → WANDER_TICK, hop animation → HOP_DONE, settle + * animation back to center on inactive, reaction timer → REACTION_DONE). + * - The click handler, which does the synchronous DOM snap to avoid + * a one-frame flicker before the reaction effect runs. + * - External reaction triggers fed by the success/aborted task counters. + */ +export function useMascotBehavior(inputs: BehaviorInputs): BehaviorOutputs { + const [state, dispatch] = useReducer(transition, INITIAL_STATE) + const wanderRef = useRef(null) + + // The wander timer's callback closes over inputs.maxAmplitude. To make + // sure resize events propagate into the next-scheduled tick without + // tearing down the timer every render, mirror it into a ref. + const maxAmpRef = useRef(inputs.maxAmplitude) + useEffect(() => { + maxAmpRef.current = inputs.maxAmplitude + }, [inputs.maxAmplitude]) + + // Tracks the WAAPI animation currently driving the wander element, if + // any. Used so the click handler can cancel an in-flight hop and so + // each new phase's effect can supersede the previous animation. + const currentAnimRef = useRef(null) + + // Tracks the phase from the prior render — lets the wander effect + // distinguish "we just landed a hop" (full random rest) from "we just + // woke up / reaction ended" (short warmup). Without this distinction + // the mascot feels sluggish after wake events. + const prevPhaseRef = useRef(INITIAL_STATE.phase) + + // ─── Sync: external isActive → SET_ACTIVE event ───────────────────── + // Single source of truth for "should we be wandering" — flows through + // the FSM rather than being checked in every effect. + useEffect(() => { + dispatch({ type: 'SET_ACTIVE', active: inputs.isActive }) + }, [inputs.isActive]) + + // ─── Sync: success/aborted task counters → EXTERNAL_REACT ─────────── + // Same pattern as the celebrate-wiggle counter in CraftBotMascot: we + // remember the prior count and dispatch a reaction when it ticks up. + // First-mount value is treated as the baseline so we don't fire on + // initial state hydration. + useReactionOnIncrease(inputs.successTaskCount, () => { + dispatch({ type: 'EXTERNAL_REACT', kind: 'happy' }) + }) + useReactionOnIncrease(inputs.abortedTaskCount, () => { + dispatch({ type: 'EXTERNAL_REACT', kind: 'frustrated' }) + }) + + // ─── Sync: isWaiting → SET_WAITING event ──────────────────────────── + // Re-checks on every (input, phase) change so that if a reaction + // briefly interrupts waitingJump, REACTION_DONE landing in 'resting' + // immediately re-promotes back to waitingJump while isWaiting is still + // true. The reducer's SET_WAITING transition is a no-op when the + // requested phase already matches, so this is safe to fire repeatedly. + useEffect(() => { + if (inputs.isWaiting && state.phase !== 'waitingJump') { + dispatch({ type: 'SET_WAITING', active: true }) + } else if (!inputs.isWaiting && state.phase === 'waitingJump') { + dispatch({ type: 'SET_WAITING', active: false }) + } + }, [inputs.isWaiting, state.phase]) + + // ─── Effect: idle-mode cycle timer ────────────────────────────────── + // Self-rescheduling recursive timeout that fires CYCLE_IDLE_MODE on + // the desired cadence for the *lifetime of the hook*. + // + // CRITICAL: this effect runs once on mount and is not redone on phase + // changes. An earlier version had `[state.phase, state.idleMode]` as + // deps, but the hop+rest cycle changes phase every 1.3–5.5s, which + // tore down and rebuilt the 12–20s cycle timer faster than it could + // ever fire — eye tracking effectively never triggered. Decoupling + // from phase fixes that. + // + // It's safe to fire CYCLE_IDLE_MODE while the FSM is in non-idle + // phases (reacting / inactive / waitingJump): the reducer's guard + // short-circuits those into a no-op and the next tick is already + // scheduled, so cycling resumes naturally once we're back in idle. + useEffect(() => { + let timerId: number | null = null + const schedule = () => { + timerId = window.setTimeout(() => { + timerId = null + dispatch({ type: 'CYCLE_IDLE_MODE' }) + schedule() + }, pickIdleCycleDelay()) + } + schedule() + return () => { + if (timerId !== null) window.clearTimeout(timerId) + } + }, []) + + // ─── Effect: wander timer (resting → hopping) ──────────────────────── + // Fires WANDER_TICK after a delay. Warmup is used for the first + // resting tick after waking/reaction; full random rest is used after + // hops so the rhythm matches the old hop+rest cycle (1300–5500ms total). + // + // Skipped entirely in track mode — that's the "stand still, eyes + // follow cursor" idle behavior. The cycle timer above will eventually + // flip idleMode back to 'wander' and re-arm this effect. + useEffect(() => { + const prevPhase = prevPhaseRef.current + prevPhaseRef.current = state.phase + + if (state.phase !== 'resting') return + if (state.idleMode === 'track') return + + const delay = prevPhase === 'hopping' ? pickPostHopRest() : WANDER_WARMUP_MS + const id = window.setTimeout(() => { + dispatch({ type: 'WANDER_TICK', maxAmp: maxAmpRef.current }) + }, delay) + return () => window.clearTimeout(id) + }, [state.phase, state.idleMode]) + + // ─── Effect: hop animation (drives HOP_DONE) ───────────────────────── + // When the FSM enters 'hopping', start the WAAPI keyframe sequence + // from state.position → state.hopTarget. onfinish dispatches HOP_DONE + // which the reducer turns into 'resting' with the new position. + // + // IMPORTANT: no cleanup function here. Returning `() => anim.cancel()` + // looks defensive but it teleports — cleanup runs on every dep change + // including the natural HOP_DONE → 'resting' transition, and cancel() + // on a finished+fill:forwards animation REMOVES the fill effect, + // snapping the element back to its un-animated style (translateX(0)). + // Instead we let each animation run to completion (its onfinish fires + // HOP_DONE) or be overridden by the next phase's animation (WAAPI + // composites; the newer animation wins on the same property). + // Interruptions that need an explicit cancel are owned by the *next* + // setup (settle pre-cancel) or by the click handler. + useEffect(() => { + if (state.phase !== 'hopping') return + const el = wanderRef.current + if (!el) return + + const anim = el.animate( + buildHopKeyframes(state.position, state.hopTarget), + { duration: HOP_DURATION_MS, easing: 'ease-in-out', fill: 'forwards' }, + ) + currentAnimRef.current = anim + anim.onfinish = () => { + if (currentAnimRef.current === anim) currentAnimRef.current = null + dispatch({ type: 'HOP_DONE' }) + } + anim.oncancel = () => { + if (currentAnimRef.current === anim) currentAnimRef.current = null + } + }, [state.phase, state.position, state.hopTarget]) + + // ─── Effect: settle to center on 'inactive' ────────────────────────── + // When the mascot has to stop wandering (action arrives, panel + // collapses, agent goes to sleep), animate the body smoothly back to + // position 0. The CSS layer separately switches between mascotLeft and + // mascotCenter; this only handles the within-layer translateX reset. + useEffect(() => { + if (state.phase !== 'inactive') return + const el = wanderRef.current + if (!el) return + + const currentX = readCurrentTranslateX(el) + if (Math.abs(currentX) < 1) { + // Already at center. Clear any leftover inline transform so future + // animations have a clean starting state. + el.style.transform = '' + return + } + + // If a hop was in flight, commit its current visual state to inline + // style before canceling. Without commitStyles, cancel removes the + // animation's fill effect and snaps the element back to its + // un-animated style — which would then disagree with the settle + // keyframes' starting position and cause a one-frame snap. + if (currentAnimRef.current) { + try { currentAnimRef.current.commitStyles() } catch { /* noop */ } + currentAnimRef.current.cancel() + currentAnimRef.current = null + } + + const anim = el.animate( + buildSettleKeyframes(currentX, 0), + { duration: SETTLE_DURATION_MS, easing: 'ease-in-out', fill: 'forwards' }, + ) + currentAnimRef.current = anim + const clear = () => { + if (currentAnimRef.current === anim) currentAnimRef.current = null + } + anim.onfinish = clear + anim.oncancel = clear + }, [state.phase]) + + // ─── Effect: reaction timer (reacting → resting) ───────────────────── + // The snap-to-ground inline-style write happens *synchronously* in the + // click handler below to avoid a one-frame flicker; this effect only + // schedules the timeout that ends the reaction. + useEffect(() => { + if (state.phase !== 'reacting') return + const id = window.setTimeout(() => { + dispatch({ type: 'REACTION_DONE' }) + }, HAPPY_DURATION_MS) + return () => window.clearTimeout(id) + }, [state.phase]) + + // ─── Effect: waitingJump animation loop ───────────────────────────── + // While phase === 'waitingJump', the mascot stands at center and + // loops vertical jumps. If it entered waitingJump from a non-center + // X (mid-wander hop, for example), settle to center first, THEN start + // the loop. The loop schedules each jump with a randomized rest + // between them so it doesn't feel mechanical. + // + // The `cancelled` closure flag (rather than a ref) is what stops the + // loop on cleanup — every async continuation checks it before + // scheduling the next step. Animations and timers both get cleaned up. + useEffect(() => { + if (state.phase !== 'waitingJump') return + const el = wanderRef.current + if (!el) return + + let cancelled = false + let restTimerId: number | null = null + + const playJump = () => { + if (cancelled) return + const node = wanderRef.current + if (!node) return + const anim = node.animate( + buildJumpInPlaceKeyframes(), + { duration: JUMP_IN_PLACE_DURATION_MS, easing: 'ease-in-out', fill: 'forwards' }, + ) + currentAnimRef.current = anim + anim.onfinish = () => { + if (currentAnimRef.current === anim) currentAnimRef.current = null + if (cancelled) return + restTimerId = window.setTimeout(playJump, pickJumpRest()) + } + anim.oncancel = () => { + if (currentAnimRef.current === anim) currentAnimRef.current = null + } + } + + // Settle to center first if needed. Without this, an entry from + // mid-hop would have the jumps happen at the hop's last X instead + // of the stage center. + const currentX = readCurrentTranslateX(el) + if (Math.abs(currentX) >= 1) { + if (currentAnimRef.current) { + try { currentAnimRef.current.commitStyles() } catch { /* noop */ } + currentAnimRef.current.cancel() + currentAnimRef.current = null + } + const settleAnim = el.animate( + buildSettleKeyframes(currentX, 0), + { duration: SETTLE_DURATION_MS, easing: 'ease-in-out', fill: 'forwards' }, + ) + currentAnimRef.current = settleAnim + settleAnim.onfinish = () => { + if (currentAnimRef.current === settleAnim) currentAnimRef.current = null + if (cancelled) return + playJump() + } + settleAnim.oncancel = () => { + if (currentAnimRef.current === settleAnim) currentAnimRef.current = null + } + } else { + playJump() + } + + return () => { + cancelled = true + if (restTimerId !== null) { + window.clearTimeout(restTimerId) + restTimerId = null + } + } + }, [state.phase]) + + // ─── Click handler ────────────────────────────────────────────────── + // Synchronous DOM work happens here (cancel anim, snap inline style) + // rather than in a useEffect, so the body settles in the same frame + // as the click event. The dispatch then notifies the FSM, which on + // next render drives the reaction effect's timer. + const handleClick = useCallback(() => { + if (!inputs.isClickable) return + if (state.phase === 'reacting') return + + const el = wanderRef.current + if (!el) return + + // Snapshot the on-screen position BEFORE we touch anything — if + // we're mid-hop, this is what stops the reaction from snapping the + // body to the canceled hop's destination. + const currentX = readCurrentTranslateX(el) + // Capture the current full transform (matrix form including scale + + // Y offset from the in-flight hop) so the settle animation below + // can start from exactly the visual state at click time. + const computedTransform = window.getComputedStyle(el).transform + const startTransform = + !computedTransform || computedTransform === 'none' + ? 'matrix(1, 0, 0, 1, 0, 0)' + : computedTransform + + // Commit the in-flight animation's current frame to inline style + // BEFORE canceling, so the cancel doesn't yank the element back to + // its un-animated style. The settle animation below then animates + // FROM that committed state TO the ground pose. + if (currentAnimRef.current) { + try { currentAnimRef.current.commitStyles() } catch { /* noop */ } + currentAnimRef.current.cancel() + currentAnimRef.current = null + } + + // Instead of an instant snap to ground, run a short ease-out from + // the click-moment pose to the standing pose. Mid-hop, the body has + // a non-zero Y (up to −22px at peak) and non-unit scale; an instant + // snap to translate(currentX, 0) scale(1, 1) jumps the body up to + // 25px vertically + visibly resizes it, which reads as a teleport. + // 150ms is short enough that the reaction's > < eyes and ray burst + // still feel instantly responsive, but long enough that the body + // motion is perceived as a deliberate settle. + // + // Visual X stays constant throughout the interpolation because both + // keyframes resolve to the same on-screen X (readCurrentTranslateX + // already accounts for the scale × transform-origin offset, so the + // ground keyframe's matrix has the same effective X as the source). + const settleAnim = el.animate( + [ + { transform: startTransform, offset: 0 }, + { transform: `matrix(1, 0, 0, 1, ${currentX}, 0)`, offset: 1 }, + ], + { duration: 150, easing: 'ease-out', fill: 'forwards' }, + ) + currentAnimRef.current = settleAnim + const clearSettle = () => { + if (currentAnimRef.current === settleAnim) currentAnimRef.current = null + } + settleAnim.onfinish = clearSettle + settleAnim.oncancel = clearSettle + + // Wake from sleep if needed. The external resetIdleTimer triggers + // a state update; the SET_ACTIVE sync effect will then transition + // the FSM out of 'inactive' on the next render. Our CLICK dispatch + // below also lifts the FSM into 'reacting' even from 'inactive', + // so the reaction starts immediately without waiting for the wake. + if (inputs.isAsleep) { + inputs.onWakeFromSleep() + } + + dispatch({ type: 'CLICK', currentVisualX: currentX }) + }, [state.phase, inputs]) + + // Bubble side derivation. While hopping, we use the hop's destination + // rather than the (pre-hop) rest position — so when the mascot leaps + // toward an edge, the bubble pre-switches to the safe side and is + // already there as the mascot lands. Outside of hopping, state.position + // is the current rest position. + const effectiveX = state.phase === 'hopping' ? state.hopTarget : state.position + // Threshold at 0 (stage midpoint). The mascot's amplitude is symmetric + // around 0 so any positive X means "right of center" → bubble on the + // left, and vice versa. No hysteresis needed because hop crossings of + // center are infrequent and the bubble swap happens during the hop's + // own motion, masking any visual snap. + const bubbleSide: 'left' | 'right' = effectiveX > 0 ? 'left' : 'right' + + return { + wanderRef, + phase: state.phase, + facing: (state as EngineState).facing, + reaction: state.phase === 'reacting' ? state.reactionKind : null, + bubbleSide, + isEyeTracking: state.phase === 'resting' && state.idleMode === 'track', + handleClick, + } +} + +/** Fire `onIncrease` exactly when `counter` ticks up. Mount-time value is + * treated as the baseline so initial hydration doesn't spuriously fire. + * Used to translate the success/aborted task counters from useMascotState + * into one-shot EXTERNAL_REACT dispatches. */ +function useReactionOnIncrease(counter: number | undefined, onIncrease: () => void): void { + const value = counter ?? 0 + const prev = useRef(value) + // We keep the callback in a ref so consumers can pass an inline arrow + // without forcing the effect to re-run on every parent render. + const cb = useRef(onIncrease) + useEffect(() => { cb.current = onIncrease }, [onIncrease]) + + useEffect(() => { + if (value > prev.current) cb.current() + prev.current = value + }, [value]) +} diff --git a/app/ui_layer/components/Mascot/useMascotNarration.ts b/app/ui_layer/components/Mascot/useMascotNarration.ts new file mode 100644 index 000000000..3d35e133d --- /dev/null +++ b/app/ui_layer/components/Mascot/useMascotNarration.ts @@ -0,0 +1,456 @@ +import { useEffect, useRef, useState } from 'react' +import { useWebSocket } from '../../browser/frontend/src/contexts/WebSocketContext' +import { parseDict } from '../../browser/frontend/src/pages/Tasks/actionRenderers/parse' +import { + getMascotFormatter, + toMascotResultStatus, + type MascotActionFormat, + type MascotActionStatus, +} from '../../browser/frontend/src/pages/Tasks/actionRenderers/mascotFormatters' +import type { ActionItem } from '../../browser/frontend/src/types' +import { MESSAGE_ACTIONS, normalizeActionName } from './mascotEngine' +import { formatMessage } from './narrationFormat' +import type { MascotState } from './types' + +// How long each narration phase holds before transitioning. The spec was +// "after 5 seconds" for both the running-floor and the result-display +// windows, so the same constant feeds both. +const PHASE_DURATION_MS = 5000 + +// Parallel-batch dedupe window. When the agent's loop fires multiple +// actions roughly simultaneously, they all show up with createdAt +// timestamps within milliseconds of each other. Without this dedupe, +// every action goes through the full 5s + 5s narration cycle — so a +// 5-action parallel batch becomes ~50s of bubble backlog and the +// mascot falls badly out of sync with reality. +// When the FSM picks one action, any OTHER unnarrated action whose +// createdAt sits within this window of the picked one is treated as +// a sibling and marked as already-narrated (silently skipped). The +// window is wide enough to catch parallel batches but narrower than +// a typical LLM-loop-step gap, so genuinely sequential actions don't +// get folded together. +// TERRIBLE implementation but there is no easier way to detect +// parallel actions without revamping the agent base code. +const PARALLEL_BATCH_MS = 1500 + +// Action names that are never narrated as a normal running/result pair. +// task_end is signaled by a celebrate/frustrate body reaction instead. +const SKIP_ACTION_NAMES: ReadonlySet = new Set(['task_end']) + +/** Discriminated union describing what the speech bubble should render. + * `null` (returned alongside in the snapshot) means "no bubble at all". + * + * The `action` kind carries a pre-formatted MascotActionFormat — the + * same POJO shape that mascotFormatters.ts (colocated with the Tasks + * panel renderers) produces. Keeping the formatting decisions over + * there means the bubble's per-action display stays in sync with the + * card-sized renderer for the same action. */ +export type NarrationContent = + | { kind: 'action'; format: MascotActionFormat } + | { kind: 'message'; text: string } + | { kind: 'thinking' } + | { kind: 'waiting' } + +interface NarrationSnapshot { + bubble: NarrationContent | null +} + +// ───────────────────────────────────────────────────────────────────── +// Internal state machine +// ───────────────────────────────────────────────────────────────────── +// +// Phases: +// - idle: no current action narrated; no bubble (unless task active +// and we're between actions → 'thinking' is chosen instead). +// - running: showing "Running with ". Held for at least +// PHASE_DURATION_MS, AND until the action itself completes +// (whichever is later). Then → result. +// - result: showing the action's output. Held for PHASE_DURATION_MS. +// - message: alternate "single bubble" lane for send_message family — +// just the message text, held for PHASE_DURATION_MS. +// - thinking: between actions while a task is still running. Stays until +// a new narratable action appears or the task ends. +// +// Selection rule: when a phase ends, we pick the EARLIEST (smallest +// createdAt) action that hasn't been narrated yet AND isn't in +// SKIP_ACTION_NAMES. send_message family routes into the 'message' phase; +// everything else routes through 'running' → 'result'. + +type InternalPhase = 'idle' | 'running' | 'result' | 'message' | 'thinking' + +interface InternalState { + phase: InternalPhase + /** ID of the action being narrated (running/result/message); null otherwise. */ + actionId: string | null + /** Wall-clock timestamp the phase was entered. Used for elapsed timing. */ + enteredAt: number +} + +const INITIAL: InternalState = { phase: 'idle', actionId: null, enteredAt: 0 } + +// ───────────────────────────────────────────────────────────────────── +// Pure helpers — operate on inputs, return next state or selection +// ───────────────────────────────────────────────────────────────────── + +/** A task is "active" for narration purposes if it can still produce or + * hold actions — running/waiting/paused. Completed, cancelled, and + * errored tasks are terminal: their leftover actions should never be + * picked up by future narration cycles. */ +const ACTIVE_TASK_STATUSES: ReadonlySet = new Set([ + 'running', + 'waiting', + 'paused', +]) + +/** Set of task IDs whose status is currently active. Used as the + * membership filter for action eligibility — an action is narratable + * only if its root task is in this set. */ +function activeTaskIds(actions: ActionItem[]): Set { + const ids = new Set() + for (const a of actions) { + if (a.itemType === 'task' && ACTIVE_TASK_STATUSES.has(a.status)) { + ids.add(a.id) + } + } + return ids +} + +/** Walk parentId up to the root task. Actions are typically direct + * children of tasks (one hop), but the walk is bounded to handle any + * future nested-action structures defensively without risk of cycles. */ +function findRootTaskId( + itemMap: ReadonlyMap, + start: ActionItem, +): string | null { + let cur: ActionItem | undefined = start + for (let depth = 0; cur && depth < 16; depth++) { + if (cur.itemType === 'task') return cur.id + if (!cur.parentId) return null + cur = itemMap.get(cur.parentId) + } + return null +} + +function isTaskActive(actions: ActionItem[]): boolean { + return activeTaskIds(actions).size > 0 +} + +/** Filter the action list down to narratable candidates and sort by + * ascending createdAt. The earliest unnarrated one wins selection. + * + * Eligibility rules: + * 1. Item type is 'action' (not 'task' or 'reasoning'). + * 2. Name isn't in the always-skip set (task_end → body reaction + * instead of narration). + * 3. The action's root task is in the active set. THIS IS THE KEY + * guard against stale narration: when a previous task ends with + * actions that were never narrated (because they piled up faster + * than the FSM could play them), those actions stay in the list + * forever — but their root task is terminal, so they're excluded. + * Only the currently-running task's actions survive the filter. */ +function listNarratableActions(actions: ActionItem[]): ActionItem[] { + const activeIds = activeTaskIds(actions) + if (activeIds.size === 0) return [] + + const itemMap = new Map() + for (const a of actions) itemMap.set(a.id, a) + + return actions + .filter(a => a.itemType === 'action') + .filter(a => !SKIP_ACTION_NAMES.has(normalizeActionName(a.name))) + .filter(a => { + const rootId = findRootTaskId(itemMap, a) + return rootId !== null && activeIds.has(rootId) + }) + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) +} + +/** Pick the next action to narrate AND mark its parallel siblings as + * narrated (a side effect on `narratedRef`). The "siblings" are any + * other unnarrated narratable actions whose createdAt is within + * PARALLEL_BATCH_MS of the picked one — they're treated as part of + * the same agent-loop parallel batch and silently skipped, so the + * bubble doesn't fall behind by playing N × 10s for an N-wide batch. + * + * send_message family is EXEMPT from sibling-skipping — they're + * user-facing utterances that must be preserved even when fired in + * the same batch as regular actions. The preemption check in the + * FSM effect routes them through the 'message' phase separately. + * + * Takes a pre-filtered `narratable` list (from listNarratableActions) + * rather than the raw actions array, so the effect can compute that + * list once and pass it to both this and `findUrgentMessage`. */ +function pickNextActionDeduped( + narratable: ActionItem[], + narratedRef: { current: Set }, +): ActionItem | null { + let picked: ActionItem | null = null + for (const a of narratable) { + if (!narratedRef.current.has(a.id)) { picked = a; break } + } + if (!picked) return null + + const pickedT = picked.createdAt ?? 0 + for (const a of narratable) { + if (a.id === picked.id) continue + if (narratedRef.current.has(a.id)) continue + // Never dedupe a message — those go through the real-time + // preemption path and must remain selectable. + if (MESSAGE_ACTIONS.has(normalizeActionName(a.name))) continue + if ((a.createdAt ?? 0) <= pickedT + PARALLEL_BATCH_MS) { + narratedRef.current.add(a.id) + } + } + return picked +} + +/** Look for an unnarrated send_message family action. Returns the + * EARLIEST one (so multiple messages play sequentially in order). + * Used by the FSM effect to preempt the current phase the moment a + * message becomes available — that's what makes message bubbles + * feel real-time instead of queued behind running/result phases. */ +function findUrgentMessage(narratable: ActionItem[], narrated: ReadonlySet): ActionItem | null { + for (const a of narratable) { + if (narrated.has(a.id)) continue + if (MESSAGE_ACTIONS.has(normalizeActionName(a.name))) return a + } + return null +} + +/** State factories — every transition lands on one of these, so wrapping + * them keeps `enteredAt: Date.now()` consistent and avoids the (3x) + * repetition of the same object literal across the FSM. */ +function stateForAction(action: ActionItem): InternalState { + const isMessage = MESSAGE_ACTIONS.has(normalizeActionName(action.name)) + return { + phase: isMessage ? 'message' : 'running', + actionId: action.id, + enteredAt: Date.now(), + } +} + +function stateThinking(): InternalState { + return { phase: 'thinking', actionId: null, enteredAt: Date.now() } +} + +function stateIdle(): InternalState { + return { phase: 'idle', actionId: null, enteredAt: 0 } +} + +// ───────────────────────────────────────────────────────────────────── +// Hook +// ───────────────────────────────────────────────────────────────────── + +interface NarrationOptions { + /** Current mascot state — used to surface the 'waiting' override bubble. */ + mascotState: MascotState +} + +export function useMascotNarration({ mascotState }: NarrationOptions): NarrationSnapshot { + const { actions } = useWebSocket() + const [internal, setInternal] = useState(INITIAL) + const narratedRef = useRef>(new Set()) + // Mirror actions into a ref so timer callbacks see the latest list + // without restarting the timer on every action update. + const actionsRef = useRef(actions) + useEffect(() => { actionsRef.current = actions }, [actions]) + + // Prune the narrated set whenever the FSM lands in idle (= the task + // wrapped up). Past task action IDs would otherwise accumulate in + // narratedRef for the lifetime of the page — they're filtered out + // by the terminal-task check in listNarratableActions anyway, so + // keeping them around just costs memory. + useEffect(() => { + if (internal.phase === 'idle') narratedRef.current.clear() + }, [internal.phase]) + + // ── Transition engine ───────────────────────────────────────────── + // + // Re-evaluated whenever: + // - The current internal phase changes (transition just happened). + // - The action list changes (new action arrived, current action's + // status flipped). + // + // Decides whether to: + // - Transition immediately (e.g. idle → running because an action + // just arrived). + // - Schedule a timer for the next deadline (5s minimum on running; + // 5s display on result/message). + useEffect(() => { + // Compute the narratable candidate list ONCE per effect run, reuse + // for both the preemption check and the synchronous phase logic + // below. listNarratableActions builds an item-id Map + filter + + // sort, so paying for it once instead of three times per effect is + // a worthwhile win on long action lists. + const narratable = listNarratableActions(actions) + + // ── Real-time send_message preemption ──────────────────────── + // send_message family bubbles bypass the normal queue. The moment + // an unnarrated message appears, the FSM jumps to its 'message' + // phase regardless of what was previously being shown. This makes + // the bubble behave the same way the 'waiting for your reply' + // state override does — chat-style real-time delivery rather than + // queued-behind-running/result. + // + // The in-flight action (if any) is marked as narrated to prevent + // it from looping back as the "next" candidate when the message + // phase later promotes — its narration is effectively cut short. + const urgent = findUrgentMessage(narratable, narratedRef.current) + if (urgent && (internal.phase !== 'message' || internal.actionId !== urgent.id)) { + if (internal.actionId && internal.actionId !== urgent.id) { + narratedRef.current.add(internal.actionId) + } + setInternal(stateForAction(urgent)) + return + } + + // Shared "look for a queued action and either start it or fall back + // to thinking/idle" helper. Used by every "phase ended, what now?" + // codepath below. The `whenEmpty` argument decides what to do when + // there's nothing queued — 'thinking' for between-action gaps, + // 'idle' for after-task cleanup. + // + // Recomputes the narratable list from actionsRef because this fires + // out of setTimeout callbacks where the closure's `narratable` is + // stale; sync callers above can reuse the top-of-effect snapshot. + const promoteOrFallback = (whenEmpty: 'thinking' | 'idle') => { + const list = actionsRef.current + if (!isTaskActive(list)) { + setInternal(stateIdle()) + return + } + const next = pickNextActionDeduped(listNarratableActions(list), narratedRef) + if (next) { + setInternal(stateForAction(next)) + return + } + setInternal(whenEmpty === 'thinking' ? stateThinking() : stateIdle()) + } + + const finishCurrent = () => { + if (internal.actionId) narratedRef.current.add(internal.actionId) + promoteOrFallback('thinking') + } + + switch (internal.phase) { + case 'idle': { + // Don't start narrating anything if there's no active task — + // even if unnarrated actions linger (they're stale leftovers + // from a previous task that already ended). + if (!isTaskActive(actions)) return + const next = pickNextActionDeduped(narratable, narratedRef) + setInternal(next ? stateForAction(next) : stateThinking()) + return + } + + case 'thinking': { + // If task is done, drop the bubble — agent has nothing more to say. + if (!isTaskActive(actions)) { + setInternal(stateIdle()) + return + } + const next = pickNextActionDeduped(narratable, narratedRef) + if (next) setInternal(stateForAction(next)) + return + } + + case 'running': { + // Held until BOTH (a) the 5s floor elapsed AND (b) the action is no + // longer in 'running' status. Whichever comes second wins. + const item = actions.find(a => a.id === internal.actionId) + if (!item) { + // Action vanished — defensive: skip to next. + finishCurrent() + return + } + const elapsed = Date.now() - internal.enteredAt + const floorRemaining = Math.max(0, PHASE_DURATION_MS - elapsed) + const stillRunning = item.status === 'running' || item.status === 'pending' + + if (!stillRunning && floorRemaining === 0) { + // Both conditions met — transition to result now. + setInternal({ phase: 'result', actionId: internal.actionId, enteredAt: Date.now() }) + return + } + if (!stillRunning) { + // Action done but floor not yet elapsed — schedule the swap. + const id = window.setTimeout(() => { + setInternal({ phase: 'result', actionId: internal.actionId, enteredAt: Date.now() }) + }, floorRemaining) + return () => window.clearTimeout(id) + } + // Still running. No timer needed — we'll be re-evaluated when the + // action's status flips (actions effect dep). + return + } + + case 'result': + case 'message': { + // Held for a fixed PHASE_DURATION_MS, then move on. + const elapsed = Date.now() - internal.enteredAt + const remaining = Math.max(0, PHASE_DURATION_MS - elapsed) + if (remaining === 0) { + finishCurrent() + return + } + const id = window.setTimeout(finishCurrent, remaining) + return () => window.clearTimeout(id) + } + } + }, [internal, actions]) + + return { bubble: resolveBubble(internal, actions, mascotState) } +} + +// ───────────────────────────────────────────────────────────────────── +// Render-side resolution +// ───────────────────────────────────────────────────────────────────── + +/** Map the FSM state + current action list + mascot state to what the + * speech bubble should actually render. Pulled out of the hook body so + * the hook is just state-machine plumbing; this is all view logic. + * + * The 'waiting' mascot-state is a hard override on top of the internal + * FSM — if the mascot reports waiting (agent paused on a user reply), + * that always takes precedence over whatever the FSM was saying. */ +function resolveBubble( + internal: InternalState, + actions: ActionItem[], + mascotState: MascotState, +): NarrationContent | null { + if (mascotState === 'waiting') return { kind: 'waiting' } + + switch (internal.phase) { + case 'idle': + return null + case 'thinking': + return { kind: 'thinking' } + case 'running': { + const item = actions.find(a => a.id === internal.actionId) + if (!item) return null + const formatter = getMascotFormatter(item.name) + return { kind: 'action', format: formatter.running(parseDict(item.input)) } + } + case 'result': { + const item = actions.find(a => a.id === internal.actionId) + if (!item) return null + const formatter = getMascotFormatter(item.name) + return { + kind: 'action', + format: formatter.result(parseDict(item.input), parseDict(item.output), toMascotResultStatus(item.status)), + } + } + case 'message': { + const item = actions.find(a => a.id === internal.actionId) + if (!item) return null + return { + kind: 'message', + text: formatMessage(parseDict(item.input)), + } + } + } +} + +// Re-export so consumers (SpeechBubble) can type their props against +// the POJO shape without reaching into the renderer package directly. +export type { MascotActionFormat, MascotActionStatus } diff --git a/app/ui_layer/components/Mascot/useMascotState.ts b/app/ui_layer/components/Mascot/useMascotState.ts new file mode 100644 index 000000000..b6459482d --- /dev/null +++ b/app/ui_layer/components/Mascot/useMascotState.ts @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWebSocket } from '../../browser/frontend/src/contexts/WebSocketContext' +import { useDerivedAgentStatus } from '../../browser/frontend/src/hooks/useDerivedAgentStatus' +import type { ActionItem } from '../../browser/frontend/src/types' +import type { MascotState } from './types' + +export interface MascotStateSnapshot { + state: MascotState + /** The latest in-progress action, if any. Used later by the action slot. */ + currentAction: ActionItem | null + /** Total completed actions+tasks. CraftBotMascot wiggles when this rises. */ + completedCount: number + /** Count of tasks (itemType === 'task') currently in the 'completed' + * status. Used by useMascotBehavior to fire the happy reaction when + * a task wraps up successfully. Monotonic in practice — tasks don't + * leave 'completed' — so the rising edge is what triggers the react. */ + successTaskCount: number + /** Count of tasks currently in an aborted status ('cancelled' or 'error'). + * Rises trigger the frustrated reaction. */ + abortedTaskCount: number + /** Human-readable label suitable for the display panel's status line. */ + label: string + /** Manually restart the idle-to-sleep countdown. Used by the click + * handler to wake the mascot when it's been idle long enough to + * enter the sleeping pose — clicking should rouse it. */ + resetIdleTimer: () => void +} + +// How long the agent must stay continuously idle before the mascot transitions +// from the "resting" pose (awake but quiet) into the full sleeping pose. +const IDLE_TO_SLEEP_MS = 30 * 60 * 1000 + +// Coarse re-render interval used to re-evaluate the idle threshold while +// the agent is idle. One minute is plenty — the 30-min gate doesn't need +// frame-accurate timing, and we don't want to wake React up every second. +const IDLE_TICK_MS = 60_000 + +// Bridges the browser-app's agent status signals into the mascot's +// vocabulary. Lives in the shared mascot folder so the React display panel +// is self-contained, but the imports above are intentionally one-way (mascot +// reads from the browser frontend, never the other direction) — when a CLI +// mascot lands it'll get its own equivalent hook reading from its own state. +export function useMascotState(): MascotStateSnapshot { + const { actions, messages, connected } = useWebSocket() + const status = useDerivedAgentStatus({ actions, messages, connected }) + + // Compute the "raw" snapshot — the state the agent's signals point to, + // before the sleep-delay rule kicks in. If raw state is 'idle', the + // gating logic below decides whether to surface it as 'idle' (sleeping) + // or 'resting' (awake-but-quiet). + const raw = useMemo(() => { + const running = actions + .filter(a => a.itemType === 'action' && a.status === 'running') + .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) + const currentAction = running[0] ?? null + + const completedCount = actions.filter( + a => (a.itemType === 'action' || a.itemType === 'task') && a.status === 'completed' + ).length + + const successTaskCount = actions.filter( + a => a.itemType === 'task' && a.status === 'completed' + ).length + + const abortedTaskCount = actions.filter( + a => a.itemType === 'task' && (a.status === 'cancelled' || a.status === 'error') + ).length + + const hasPausedTask = actions.some( + a => a.itemType === 'task' && a.status === 'paused' + ) + + // Priority resolution: error > waiting > paused > working/thinking > idle. + let rawState: MascotState + if (status.state === 'error') { + rawState = 'error' + } else if (status.state === 'waiting') { + rawState = 'waiting' + } else if (hasPausedTask) { + rawState = 'paused' + } else if (status.state === 'working') { + rawState = currentAction ? 'working' : 'thinking' + } else if (status.state === 'thinking') { + rawState = 'thinking' + } else { + rawState = 'idle' + } + + return { rawState, currentAction, completedCount, successTaskCount, abortedTaskCount } + }, [actions, messages, connected, status]) + + // Timestamp of when the agent most recently became idle. Reset whenever + // the raw state moves to anything non-idle. Kept in a ref so it survives + // re-renders without forcing them. + const idleStartRef = useRef(null) + + // Tick counter — bumped on a coarse interval while idle so the memo + // re-evaluates and can flip raw 'idle' → resolved 'idle' once the + // 30-minute threshold passes. + const [tick, setTick] = useState(0) + + useEffect(() => { + if (raw.rawState === 'idle') { + if (idleStartRef.current === null) { + idleStartRef.current = Date.now() + } + } else { + // Any non-idle state resets the timer so a brief flash of activity + // restarts the 30-minute window from scratch. + idleStartRef.current = null + } + }, [raw.rawState]) + + useEffect(() => { + if (raw.rawState !== 'idle') return + const id = window.setInterval(() => setTick(t => t + 1), IDLE_TICK_MS) + return () => window.clearInterval(id) + }, [raw.rawState]) + + // Resolve the public state: if raw is 'idle', gate it on the elapsed + // idle duration. Non-idle states pass through unchanged. + const state: MascotState = useMemo(() => { + if (raw.rawState !== 'idle') return raw.rawState + const startedAt = idleStartRef.current + if (startedAt === null) return 'resting' + const elapsed = Date.now() - startedAt + return elapsed >= IDLE_TO_SLEEP_MS ? 'idle' : 'resting' + // `tick` is intentionally in the dep list — it's the trigger that lets + // this memo re-run when the 30-min mark passes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raw.rawState, tick]) + + // Push the idle-start timestamp to now and bump the tick so the + // state-resolution memo re-runs and re-evaluates. If the mascot was + // sleeping ('idle'), this drops it back into 'resting' immediately. + const resetIdleTimer = useCallback(() => { + idleStartRef.current = Date.now() + setTick(t => t + 1) + }, []) + + return { + state, + currentAction: raw.currentAction, + completedCount: raw.completedCount, + successTaskCount: raw.successTaskCount, + abortedTaskCount: raw.abortedTaskCount, + label: status.message, + resetIdleTimer, + } +} diff --git a/app/ui_layer/components/Mascot/useStageMeasure.ts b/app/ui_layer/components/Mascot/useStageMeasure.ts new file mode 100644 index 000000000..3c7aa28df --- /dev/null +++ b/app/ui_layer/components/Mascot/useStageMeasure.ts @@ -0,0 +1,39 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from 'react' +import { STAGE_EDGE_PADDING_PX } from './mascotEngine' + +/** Tracks the content-area width of a measured container. + * + * - Synchronous initial measurement via useLayoutEffect so the first + * render has a real number (no zero-then-resize jump). + * - ResizeObserver for ongoing updates so the wander range follows the + * chat panel as the user drags the resize handle. + * + * Returns the *content* width (= clientWidth - 2 × padding). The mascot + * layout uses this to compute the wander amplitude and the renderer- + * card's max width. + */ +export function useStageMeasure( + ref: RefObject, + paddingPx: number = STAGE_EDGE_PADDING_PX, +): number { + const [width, setWidth] = useState(0) + + useLayoutEffect(() => { + if (ref.current) { + setWidth(ref.current.clientWidth - 2 * paddingPx) + } + }, [ref, paddingPx]) + + useEffect(() => { + const el = ref.current + if (!el) return + const observer = new ResizeObserver(entries => { + const entry = entries[0] + if (entry) setWidth(entry.contentRect.width) + }) + observer.observe(el) + return () => observer.disconnect() + }, [ref]) + + return width +} diff --git a/app/ui_layer/components/Mascot/useWheelZoom.ts b/app/ui_layer/components/Mascot/useWheelZoom.ts new file mode 100644 index 000000000..ecb4ac5cd --- /dev/null +++ b/app/ui_layer/components/Mascot/useWheelZoom.ts @@ -0,0 +1,62 @@ +import { useEffect, useState, type RefObject } from 'react' + +export interface WheelZoomOptions { + /** Lower bound (zoomed-out limit). */ + min: number + /** Upper bound (zoomed-in limit). Usually the design baseline (= 1). */ + max: number + /** Per-wheel-notch step. 0.1 = 10% bigger or 10% smaller per notch. + * Applied multiplicatively (1 + step on zoom-in, 1 / (1 + step) on + * zoom-out) so the perceptual delta is the same at any zoom level. */ + step: number + /** Initial zoom value. Should sit within [min, max]; not clamped here + * so passing an out-of-range value is a visible error rather than a + * silent surprise. Defaults to `max` (full-baseline view). */ + initial?: number +} + +/** + * Bind wheel events on `ref` to a clamped, multiplicatively-stepping + * zoom value. Returns the current zoom (a number in `[min, max]`). + * + * Implementation notes: + * + * - The listener is attached via `addEventListener(..., { passive: false })` + * so `preventDefault()` actually stops the page-level scroll. React's + * `onWheel` is passive by default — using it would let the browser + * keep scrolling the page even while we're zooming inside the ref. + * + * - Plain wheel (no Ctrl modifier) so the gesture doesn't collide with + * the browser's built-in Ctrl-wheel zoom. The host element is small + * enough that there's no ambiguity — wheel on it always means zoom. + * + * - Steps are multiplicative (×(1 + step) / ÷(1 + step)) rather than + * additive so a "notch" feels equally significant at zoom 0.5 as at + * zoom 1.0 — without this, low zooms would feel slow and high zooms + * would feel jumpy. + */ +export function useWheelZoom( + ref: RefObject, + options: WheelZoomOptions, +): number { + const { min, max, step, initial = max } = options + const [zoom, setZoom] = useState(initial) + + useEffect(() => { + const el = ref.current + if (!el) return + + const onWheel = (e: WheelEvent) => { + e.preventDefault() + setZoom(z => { + const factor = e.deltaY < 0 ? 1 + step : 1 / (1 + step) + return Math.max(min, Math.min(max, z * factor)) + }) + } + + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + }, [ref, min, max, step]) + + return zoom +}