The pending operator-question card now renders inline in ChatThread (the newest thing in the conversation) instead of in the context rail — it's part of the chat, not a separate side panel, and the panel's hasContext gate no longer needs to special-case it. The desktop mascot's reactions are now entirely about whichever task window has focus, not fleet-wide events: thinking/talking is a new continuous `busy` behavior that tracks the focused session's own streaming state (thinking before any text arrives, talking once it does — using the previously-unwired peep/talk sprite), eureka fires with the actual knowledge title that was recorded, happy fires with the task's own completion summary, and alarmed now means "this task needs your OK" (an operator question was raised) rather than a fleet-wide critical/signal event. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
246 lines
8.4 KiB
Svelte
246 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
// MascotLayer: a pointer-events-none absolute inset-0 overlay at z-45
|
|
// (above WindowLayer's z-40, below the desktop context menu's z-50
|
|
// and the radial menu's z-[60]). Hosts the Mascot sprite, the
|
|
// RadialMenu, the NameDialog, and the speech/name bubble. Owns the
|
|
// MascotRuntime and the surface bounds; attaches the stimulus bus on
|
|
// mount so the mascot reacts to chat/activity/events.
|
|
//
|
|
// Insertion point: rendered inside Desktop.svelte's surface <div>
|
|
// (the `relative min-h-0 flex-1 overflow-hidden` element), after
|
|
// <WindowLayer />, so its `absolute inset-0` shares the surface's
|
|
// coordinate space and its ground line lands at the surface's bottom
|
|
// edge (= the taskbar's top edge).
|
|
|
|
import { onMount } from 'svelte'
|
|
import Mascot from './Mascot.svelte'
|
|
import RadialMenu from './RadialMenu.svelte'
|
|
import NameDialog from './NameDialog.svelte'
|
|
import { attachStimuli } from './stimuli'
|
|
import {
|
|
initMascotState,
|
|
getModel,
|
|
setName as modelSetName,
|
|
forceHatch as modelForceHatch,
|
|
setStage,
|
|
resetModel
|
|
} from '$lib/mascot/state.svelte'
|
|
import { forceBehavior } from '$lib/mascot/behavior'
|
|
import type { MascotRuntime, AnimName, BehaviorId, MascotStage } from '$lib/mascot/types'
|
|
|
|
let host = $state<HTMLDivElement | null>(null)
|
|
|
|
// The runtime is created here (fresh per mount) and seeded from the
|
|
// persisted model's lastPos + the live surface bounds. It's a `$state`
|
|
// so mutations to runtime.x/y/behavior/etc. from the FSM are tracked
|
|
// by the `$derived` position expressions in Mascot.svelte — a plain
|
|
// `let` would update internally but never re-render the canvas.
|
|
let runtime: MascotRuntime = $state({
|
|
x: 100,
|
|
y: 100,
|
|
vx: 0,
|
|
vy: 0,
|
|
facing: 1,
|
|
behavior: 'egg',
|
|
behaviorUntil: Number.POSITIVE_INFINITY,
|
|
anim: 'egg-idle',
|
|
animStart: 0,
|
|
reactAnim: null,
|
|
bounds: { w: 800, h: 600 },
|
|
groundY: 600,
|
|
bubbleText: null,
|
|
bubbleUntil: 0,
|
|
blinkUntil: 0,
|
|
fallPhaseAt: 0,
|
|
flapCycleMs: 0,
|
|
squashAt: 0,
|
|
impactVy: 0,
|
|
bounceCount: 0,
|
|
investigateOnLand: false,
|
|
busyTalking: false
|
|
})
|
|
|
|
let menuPos = $state<{ x: number; y: number } | null>(null)
|
|
let nameDialogOpen = $state(false)
|
|
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
|
|
|
|
const model = $derived(getModel())
|
|
|
|
function openMenu(x: number, y: number) {
|
|
menuPos = { x, y }
|
|
}
|
|
|
|
function closeMenu() {
|
|
menuPos = null
|
|
}
|
|
|
|
function requestRename() {
|
|
nameDialogMode = 'rename'
|
|
nameDialogOpen = true
|
|
}
|
|
|
|
function forceHatch() {
|
|
const wasUnnamed = getModel().name === null
|
|
modelForceHatch()
|
|
if (runtime.behavior === 'egg') forceBehavior(runtime, 'idle')
|
|
// If the egg was unnamed (e.g. debug force-hatch before the name
|
|
// dialog was submitted), open the name dialog so the chick gets a
|
|
// name — matching the normal hatch-on-naming flow.
|
|
if (wasUnnamed) {
|
|
nameDialogMode = 'hatch'
|
|
nameDialogOpen = true
|
|
}
|
|
}
|
|
|
|
function forceStageFn(s: MascotStage) {
|
|
setStage(s)
|
|
forceBehavior(runtime, 'idle')
|
|
}
|
|
|
|
function reset() {
|
|
resetModel()
|
|
forceBehavior(runtime, 'egg')
|
|
}
|
|
|
|
function refresh() {
|
|
// Trigger reactivity: the menu reads model visibility predicates on
|
|
// each render; touching a $state value re-runs the menu's derived
|
|
// filter. menuPos re-assignment is a no-op if already set.
|
|
menuPos = menuPos ? { ...menuPos } : null
|
|
}
|
|
|
|
function onPet() {
|
|
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
|
|
// call already happened in Mascot.svelte.
|
|
runtime.bubbleText = '❤️'
|
|
runtime.bubbleUntil = performance.now() + 1500
|
|
}
|
|
|
|
function onNameSubmit(name: string) {
|
|
modelSetName(name)
|
|
if (nameDialogMode === 'hatch') {
|
|
// Naming the egg is what hatches it — no timed incubation.
|
|
forceHatch()
|
|
}
|
|
nameDialogOpen = false
|
|
}
|
|
|
|
// Stage transitions are driven by the name-dialog submit handler
|
|
// (egg → chick on first naming) and the debug menu (force hatch /
|
|
// force stage), not by observing model.stage here. No $effect needed.
|
|
|
|
onMount(() => {
|
|
initMascotState()
|
|
// Seed runtime from persisted lastPos once we know the surface size.
|
|
const rect = host?.getBoundingClientRect()
|
|
if (rect) {
|
|
runtime.bounds = { w: rect.width, h: rect.height }
|
|
runtime.groundY = rect.height
|
|
const lp = getModel().lastPos
|
|
runtime.x = lp ? Math.max(24, Math.min(rect.width - 24, lp.x)) : rect.width / 2
|
|
runtime.y = rect.height // ground
|
|
runtime.anim = getModel().stage === 'egg' ? 'egg-idle' : 'idle'
|
|
runtime.behavior = getModel().stage === 'egg' ? 'egg' : 'idle'
|
|
}
|
|
// First-run: a fresh egg with no name prompts for naming, which
|
|
// hatches it. Returning users with a named mascot skip this.
|
|
if (getModel().stage === 'egg' && getModel().name === null) {
|
|
nameDialogMode = 'hatch'
|
|
nameDialogOpen = true
|
|
}
|
|
// Attach the stimulus bus (chat/activity/events -> reactions + the
|
|
// continuous "busy" state). Both are gated to non-egg stages: the egg
|
|
// isn't "alive" yet (no name, no hatched chick to react), so stimulus
|
|
// events are silently dropped until the egg hatches. This keeps the
|
|
// egg calm during the naming dialog rather than playing alarm
|
|
// animations behind it.
|
|
const detach = attachStimuli(
|
|
(reaction, bubbleOverride) => {
|
|
if (getModel().stage === 'egg') return
|
|
// Dragging always wins — never let a reaction interrupt an active
|
|
// drag (previously it could visually flash a reaction animation
|
|
// mid-drag, even though position tracking stayed correct; see the
|
|
// physics audit's Finding 5). Sleep is only interrupted by
|
|
// reactions that explicitly opt in via interruptsSleep.
|
|
if (runtime.behavior === 'dragged') return
|
|
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
|
|
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
|
|
// here we just force the behavior.
|
|
const anim = reaction.anim as AnimName
|
|
const id: BehaviorId = 'react'
|
|
runtime.reactAnim = anim
|
|
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
|
|
const bubble = bubbleOverride ?? reaction.bubble
|
|
if (bubble) {
|
|
runtime.bubbleText = bubble
|
|
runtime.bubbleUntil = performance.now() + reaction.durationMs
|
|
}
|
|
if (reaction.effect) reaction.effect()
|
|
},
|
|
(phase) => {
|
|
// Continuous engagement with the focused task's active turn — see
|
|
// stimuli.ts. Not a timed pulse: stays in `busy` until stimuli.ts
|
|
// reports the turn ended (phase === null), same drag/sleep gating
|
|
// as reactions above. Also never preempts an in-flight reaction
|
|
// pulse (eureka/alarmed/happy) — those are short and should play
|
|
// out; the next chat update re-affirms busy shortly after (the
|
|
// underlying store keeps emitting throughout an active turn), so
|
|
// this self-heals within a token or two rather than needing an
|
|
// explicit "resume busy after this pulse" handoff.
|
|
if (getModel().stage === 'egg') return
|
|
if (runtime.behavior === 'dragged' || runtime.behavior === 'react') return
|
|
if (phase === null) {
|
|
if (runtime.behavior === 'busy') forceBehavior(runtime, 'idle')
|
|
return
|
|
}
|
|
if (runtime.behavior === 'sleep') return
|
|
runtime.busyTalking = phase === 'talking'
|
|
if (runtime.behavior !== 'busy') forceBehavior(runtime, 'busy')
|
|
}
|
|
)
|
|
return () => detach()
|
|
})
|
|
|
|
// Action context handed to RadialMenu leaf handlers.
|
|
const actionCtx = $derived({
|
|
model,
|
|
runtime,
|
|
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) =>
|
|
forceBehavior(runtime, id, opts),
|
|
requestRename,
|
|
forceHatch,
|
|
forceStage: forceStageFn,
|
|
reset,
|
|
refresh
|
|
})
|
|
</script>
|
|
|
|
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
|
<Mascot
|
|
bind:runtime
|
|
onContextMenu={openMenu}
|
|
onPet={onPet}
|
|
onRequestName={() => {
|
|
nameDialogMode = 'hatch'
|
|
nameDialogOpen = true
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{#if menuPos}
|
|
<RadialMenu
|
|
pos={menuPos}
|
|
ctx={actionCtx}
|
|
onDismiss={closeMenu}
|
|
/>
|
|
{/if}
|
|
|
|
{#if nameDialogOpen}
|
|
<NameDialog
|
|
mode={nameDialogMode}
|
|
initial={model.name ?? ''}
|
|
onSubmit={onNameSubmit}
|
|
onCancel={() => (nameDialogOpen = false)}
|
|
/>
|
|
{/if}
|