feat(web): operator questions inline in chat, mascot reactions scoped to the focused task
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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>
This commit is contained in:
2026-07-21 10:46:46 +02:00
parent 6b6bfe1fd8
commit e5a81241b7
12 changed files with 246 additions and 136 deletions

View File

@@ -56,7 +56,8 @@
squashAt: 0,
impactVy: 0,
bounceCount: 0,
investigateOnLand: false
investigateOnLand: false,
busyTalking: false
})
let menuPos = $state<{ x: number; y: number } | null>(null)
@@ -147,33 +148,56 @@
nameDialogMode = 'hatch'
nameDialogOpen = true
}
// Attach the stimulus bus (chat/activity/events -> reactions).
// Reactions 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) => {
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 })
if (reaction.bubble) {
runtime.bubbleText = reaction.bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs
// 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')
}
if (reaction.effect) reaction.effect()
})
)
return () => detach()
})