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

@@ -11,6 +11,7 @@
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
import Spinner from './Spinner.svelte' import Spinner from './Spinner.svelte'
import ToolCallCard from './ToolCallCard.svelte' import ToolCallCard from './ToolCallCard.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left' import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import CheckIcon from '@lucide/svelte/icons/check' import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
@@ -19,6 +20,7 @@
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat' import type { ChatMessage } from '$lib/stores/chat'
import type { SessionQuestion } from '$lib/api'
let { let {
messages, messages,
@@ -31,7 +33,9 @@
onReconnect, onReconnect,
onDismissError, onDismissError,
suggestions = [], suggestions = [],
activityLog: activityLogProp = activityLog activityLog: activityLogProp = activityLog,
sessionId = null,
question = null
}: { }: {
messages: ChatMessage[] messages: ChatMessage[]
streaming: boolean streaming: boolean
@@ -44,6 +48,10 @@
onDismissError: (id: string) => void onDismissError: (id: string) => void
suggestions?: string[] suggestions?: string[]
activityLog?: Readable<ActivityEntry[]> activityLog?: Readable<ActivityEntry[]>
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
sessionId?: string | null
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
question?: SessionQuestion | null
} = $props() } = $props()
let input = $state('') let input = $state('')
@@ -121,9 +129,11 @@
scrolledUp = !isNearBottom() scrolledUp = !isNearBottom()
} }
// Auto-scroll to bottom on new messages — unless user scrolled up to read. // Auto-scroll to bottom on new messages (or a freshly-raised question) —
// unless user scrolled up to read.
$effect(() => { $effect(() => {
void messages void messages
void question
if (streaming || !scrolledUp) { if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50) setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
} }
@@ -246,6 +256,9 @@
{/if} {/if}
</div> </div>
{/each} {/each}
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div bind:this={messagesEnd}></div> <div bind:this={messagesEnd}></div>
</div> </div>
</div> </div>

View File

@@ -26,7 +26,7 @@
{#if question} {#if question}
{@const q = question} {@const q = question}
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5"> <div class="flex flex-col gap-2 rounded-2xl border border-warning/30 bg-warning/5 px-3.5 py-3">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" /> <CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">

View File

@@ -42,12 +42,14 @@
// The context rail (Scope/Activity) is only worth its screen space once // The context rail (Scope/Activity) is only worth its screen space once
// there's something in it — a brand-new task otherwise opens to an empty // there's something in it — a brand-new task otherwise opens to an empty
// "entities appear here" placeholder next to an equally empty activity // "entities appear here" placeholder next to an equally empty activity
// list. Show it the moment any of the three has real content, and keep it // list. Show it the moment either has real content, and keep it shown
// shown from then on (no flicker back to hidden if e.g. touched entities // from then on (no flicker back to hidden if e.g. touched entities later
// later expire). // expire). An open question does NOT gate this anymore — it renders
// inline in the chat thread itself (see ChatThread's `question` prop
// below), not in this rail.
let hasContext = $state(false) let hasContext = $state(false)
$effect(() => { $effect(() => {
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0 || $openQuestion !== null)) { if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
hasContext = true hasContext = true
} }
}) })
@@ -91,6 +93,8 @@
error={$chatError} error={$chatError}
chatErrors={$chatErrors} chatErrors={$chatErrors}
activityLog={sessionActivityLog} activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)} onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)} onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)} onReconnect={() => loadSessionChat(sessionId)}
@@ -109,6 +113,8 @@
error={$chatError} error={$chatError}
chatErrors={$chatErrors} chatErrors={$chatErrors}
activityLog={sessionActivityLog} activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)} onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)} onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)} onReconnect={() => loadSessionChat(sessionId)}

View File

@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes' import { Pane, Splitpanes } from 'svelte-splitpanes'
import { startWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace' import { startWorkspace, planSteps, currentTask, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat' import { streaming, messages, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity' import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte' import SessionGraph from './SessionGraph.svelte'
import UnifiedTimeline from './UnifiedTimeline.svelte' import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
@@ -26,7 +25,6 @@
const ws = $derived(sessionId ? workspaceFor(sessionId) : null) const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps) const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
const touchedStore = $derived(ws ? ws.touched : touched) const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs) const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask) const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
@@ -34,9 +32,6 @@
const streamingStore = $derived(chat ? chat.streaming : streaming) const streamingStore = $derived(chat ? chat.streaming : streaming)
const messagesStore = $derived(chat ? chat.messages : messages) const messagesStore = $derived(chat ? chat.messages : messages)
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog) const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
// OperatorQuestion posts its answer against this id — the window's own
// session when set, otherwise whatever the main page currently has open.
const effectiveSessionId = $derived(sessionId ?? $currentSession)
let scopeOpen = $state(true) let scopeOpen = $state(true)
let activityOpen = $state(true) let activityOpen = $state(true)
@@ -73,8 +68,6 @@
</script> </script>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1"> <Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
<!-- Scope --> <!-- Scope -->
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col"> <Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">

View File

@@ -9,7 +9,7 @@
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow) // session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
// new-task -> NewTaskChat (windows.ts openNewTaskWindow) // new-task -> NewTaskChat (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent // anything else -> entity slug -> EntityDetailContent
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows' import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID, SESSION_PREFIX } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps' import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte' import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte' import SessionChatWindow from '../SessionChatWindow.svelte'
@@ -18,8 +18,6 @@
import MinusIcon from '@lucide/svelte/icons/minus' import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2' import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const SESSION_PREFIX = 'session:'
// A hydrated `app:<id>` window whose id no longer matches any registry // A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has // entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank // nothing to render — close it rather than leaving a permanently-blank

View File

@@ -56,7 +56,8 @@
squashAt: 0, squashAt: 0,
impactVy: 0, impactVy: 0,
bounceCount: 0, bounceCount: 0,
investigateOnLand: false investigateOnLand: false,
busyTalking: false
}) })
let menuPos = $state<{ x: number; y: number } | null>(null) let menuPos = $state<{ x: number; y: number } | null>(null)
@@ -147,33 +148,56 @@
nameDialogMode = 'hatch' nameDialogMode = 'hatch'
nameDialogOpen = true nameDialogOpen = true
} }
// Attach the stimulus bus (chat/activity/events -> reactions). // Attach the stimulus bus (chat/activity/events -> reactions + the
// Reactions are gated to non-egg stages: the egg isn't "alive" yet // continuous "busy" state). Both are gated to non-egg stages: the egg
// (no name, no hatched chick to react), so stimulus events are // isn't "alive" yet (no name, no hatched chick to react), so stimulus
// silently dropped until the egg hatches. This keeps the egg calm // events are silently dropped until the egg hatches. This keeps the
// during the naming dialog rather than playing alarm animations // egg calm during the naming dialog rather than playing alarm
// behind it. // animations behind it.
const detach = attachStimuli((reaction) => { const detach = attachStimuli(
if (getModel().stage === 'egg') return (reaction, bubbleOverride) => {
// Dragging always wins — never let a reaction interrupt an active if (getModel().stage === 'egg') return
// drag (previously it could visually flash a reaction animation // Dragging always wins — never let a reaction interrupt an active
// mid-drag, even though position tracking stayed correct; see the // drag (previously it could visually flash a reaction animation
// physics audit's Finding 5). Sleep is only interrupted by // mid-drag, even though position tracking stayed correct; see the
// reactions that explicitly opt in via interruptsSleep. // physics audit's Finding 5). Sleep is only interrupted by
if (runtime.behavior === 'dragged') return // reactions that explicitly opt in via interruptsSleep.
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return if (runtime.behavior === 'dragged') return
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts); if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
// here we just force the behavior. // Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
const anim = reaction.anim as AnimName // here we just force the behavior.
const id: BehaviorId = 'react' const anim = reaction.anim as AnimName
runtime.reactAnim = anim const id: BehaviorId = 'react'
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs }) runtime.reactAnim = anim
if (reaction.bubble) { forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
runtime.bubbleText = reaction.bubble const bubble = bubbleOverride ?? reaction.bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs 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() return () => detach()
}) })

View File

@@ -183,7 +183,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{ {
id: 'trigger-thinking', id: 'trigger-thinking',
label: 'Trigger: Thinking', label: 'Trigger: Thinking',
description: 'Normally fires when Nomos starts streaming a reply in any open chat window.', description: 'Normally a continuous state (not a timed pulse like this button): the focused task window\'s session starts streaming a reply, before any text has arrived yet. Switches to the "talk" sprite the moment text starts appearing.',
action: (ctx) => { action: (ctx) => {
triggerReaction(ctx, 'thinking') triggerReaction(ctx, 'thinking')
ctx.refresh() ctx.refresh()
@@ -192,7 +192,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{ {
id: 'trigger-eureka', id: 'trigger-eureka',
label: 'Trigger: Eureka', label: 'Trigger: Eureka',
description: 'Normally fires when a new knowledge-graph entry is written (an "upsert_knowledge" tool result). Grants +5 xp.', description: 'Normally fires when the focused task window records new knowledge (an "upsert_knowledge" tool result) — the real bubble shows the knowledge\'s own title. Grants +5 xp.',
action: (ctx) => { action: (ctx) => {
triggerReaction(ctx, 'eureka') triggerReaction(ctx, 'eureka')
ctx.refresh() ctx.refresh()
@@ -201,7 +201,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{ {
id: 'trigger-alarmed', id: 'trigger-alarmed',
label: 'Trigger: Alarmed', label: 'Trigger: Alarmed',
description: 'Normally fires on a critical event or a new signal.* event on the live event stream. The only reaction that wakes the mascot from sleep.', description: 'Normally fires when the focused task window raises a new operator question (permission needed to proceed). The only reaction that wakes the mascot from sleep.',
action: (ctx) => { action: (ctx) => {
triggerReaction(ctx, 'alarmed') triggerReaction(ctx, 'alarmed')
ctx.refresh() ctx.refresh()
@@ -210,7 +210,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{ {
id: 'trigger-happy', id: 'trigger-happy',
label: 'Trigger: Happy', label: 'Trigger: Happy',
description: 'Normally fires when an execution.* event lands on the live event stream.', description: "Normally fires when the focused task window's task completes successfully — the real bubble shows the task's own summary.",
action: (ctx) => { action: (ctx) => {
triggerReaction(ctx, 'happy') triggerReaction(ctx, 'happy')
ctx.refresh() ctx.refresh()

View File

@@ -202,8 +202,12 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
id: 'idle', id: 'idle',
// Periodic blink: enter() sets blinkUntil to the START of the next // Periodic blink: enter() sets blinkUntil to the START of the next
// blink window (26s away). anim() returns 'blink' when we're past // blink window (26s away). anim() returns 'blink' when we're past
// that start but within 150ms of it. // that start but within 150ms of it. A chatter bubble (see enter()
// below) takes over the sprite for as long as it's showing — the
// mouth-flap 'talk' loop reads as the mascot actually saying the line
// instead of just standing there while text happens to appear above it.
anim: (rt) => { anim: (rt) => {
if (rt.bubbleText) return 'talk'
const now = performance.now() const now = performance.now()
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink' if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
return 'idle' return 'idle'
@@ -353,6 +357,27 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
next: () => 'idle', next: () => 'idle',
minMs: REACT_DEFAULT_MS, minMs: REACT_DEFAULT_MS,
maxMs: REACT_DEFAULT_MS maxMs: REACT_DEFAULT_MS
},
// Continuous engagement with the focused task window's active turn — not
// a timed pulse like `react` above. Entered/exited directly by
// MascotLayer's busy-state callback (see stimuli.ts's attachStimuli,
// second callback), which also keeps rt.busyTalking current every time
// the phase flips. No `weight` — never auto-picked by the idle selector,
// same as dragged/falling/land.
busy: {
id: 'busy',
anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'),
tick: () => {
// Stationary — just displays whichever sprite busyTalking selects.
},
// Only reached if something calls next() on it directly, which nothing
// does in practice: MascotLayer forces 'idle' itself the moment
// stimuli.ts reports the turn ended. Falling back to 'idle' here is
// just a safe default, not the real exit path.
next: () => 'idle',
minMs: 0,
maxMs: 0
} }
} }

View File

@@ -39,6 +39,7 @@ export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> =
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true }, peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true }, sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true }, 'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false }, land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
@@ -54,6 +55,7 @@ export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> =
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true }, peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true }, sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true }, dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true }, 'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false }, land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },

View File

@@ -1,23 +1,31 @@
// Stimulus / reaction system. To add a new environment reaction: // Stimulus / reaction system. To add a new environment reaction:
// 1. Add a `ReactionDef` entry to REACTIONS below. // 1. Add a `ReactionDef` entry to REACTIONS below.
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block // 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
// inside `attachStimuli()`. // inside `attachStimuli()`'s per-session bundle.
// The dispatch logic (priority + cooldown + interruptsSleep) is generic // The dispatch logic (priority + cooldown + interruptsSleep) is generic
// over REACTIONS — no engine change is needed for a new reaction. // over REACTIONS — no engine change is needed for a new reaction.
// //
// Reactions are dispatched into the MascotLayer via the `emit` callback // Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim, // passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble. `dragged` always wins over any // durationMs}) and sets the bubble (optionally overridden per-dispatch —
// reaction; `sleep` is broken only when `interruptsSleep` is true. // see the `bubbleOverride` param — so e.g. eureka can show the actual
// knowledge title instead of a generic line). `dragged` always wins over
// any reaction; `sleep` is broken only when `interruptsSleep` is true.
// //
// `attachStimuli` owns the SSE subscription (via subscribeEvents()) and // Scoping: everything below tracks whichever task/chat window currently has
// folds its unsubscribe into the returned teardown, so the mascot keeps // focus (windows.ts's focusedSessionId), re-bound every time focus moves —
// the SSE stream open (ref-counted alongside any page that also // the mascot reacts to the task the operator is actually looking at, not to
// subscribes) only while mounted. // every session fleet-wide. Nothing fires while no task window is focused.
//
// `thinking`/`talking` aren't pulse reactions at all: they're the
// continuous `busy` FSM behavior (behavior.ts), driven by the second
// `setBusy` callback rather than `emit` — see the "Busy" block below.
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events' import { derived } from 'svelte/store'
import { streaming } from '$lib/stores/chat' import { chatFor } from '$lib/stores/chat'
import { activityLog, type ActivityEntry } from '$lib/stores/activity' import { workspaceFor } from '$lib/stores/workspace'
import { activityLogFor } from '$lib/stores/activity'
import { focusedSessionId } from '$lib/stores/windows'
import { grantXp } from './state.svelte' import { grantXp } from './state.svelte'
import type { AnimName } from './types' import type { AnimName } from './types'
@@ -25,7 +33,7 @@ export interface ReactionDef {
id: string id: string
/** Animation to play while this reaction is active. */ /** Animation to play while this reaction is active. */
anim: AnimName anim: AnimName
/** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). */ /** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). Per-dispatch callers may override this — see `tryDispatch`'s bubbleOverride param. */
bubble?: string bubble?: string
/** Higher priority interrupts lower-priority reactions. */ /** Higher priority interrupts lower-priority reactions. */
priority: number priority: number
@@ -54,7 +62,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
anim: 'react-eureka', anim: 'react-eureka',
bubble: '💡 Eureka!', bubble: '💡 Eureka!',
priority: 2, priority: 2,
cooldownMs: 10_000, cooldownMs: 3000,
durationMs: 2200, durationMs: 2200,
interruptsSleep: false, interruptsSleep: false,
effect: () => grantXp(5) effect: () => grantXp(5)
@@ -62,9 +70,9 @@ export const REACTIONS: Record<string, ReactionDef> = {
alarmed: { alarmed: {
id: 'alarmed', id: 'alarmed',
anim: 'react-alarm', anim: 'react-alarm',
bubble: '❗ Uh oh!', bubble: '❗ Need your OK',
priority: 3, priority: 3,
cooldownMs: 15_000, cooldownMs: 3000,
durationMs: 2500, durationMs: 2500,
interruptsSleep: true interruptsSleep: true
}, },
@@ -73,7 +81,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
anim: 'react-happy', anim: 'react-happy',
bubble: '🎉 Nice work!', bubble: '🎉 Nice work!',
priority: 1, priority: 1,
cooldownMs: 20_000, cooldownMs: 3000,
durationMs: 2000 durationMs: 2000
} }
} }
@@ -85,80 +93,100 @@ const lastFired = new Map<string, number>()
let currentReactPriority = 0 let currentReactPriority = 0
let currentReactUntil = 0 let currentReactUntil = 0
const BUBBLE_MAX = 70
/** Truncate a dynamic bubble line (a knowledge title, a task summary) to something that still fits the speech bubble. */
function truncate(s: string, max = BUBBLE_MAX): string {
return s.length > max ? `${s.slice(0, max - 1)}` : s
}
/** /**
* Attach all stimulus subscriptions. Returns a teardown that detaches * Attach all stimulus subscriptions. Returns a teardown that detaches
* everything (including the SSE stream ref). The `emit` callback is the * everything. `emit` is the MascotLayer's bridge for pulse reactions
* MascotLayer's bridge into the runtime — it decides whether to actually * (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch);
* dispatch based on the current behavior (dragged always wins). * `setBusy` is its bridge for the continuous thinking/talking state (no
* gating — it's a live phase, not a discrete event).
*/ */
export function attachStimuli(emit: (r: ReactionDef) => void): () => void { export function attachStimuli(
emit: (r: ReactionDef, bubbleOverride?: string) => void,
setBusy: (phase: 'thinking' | 'talking' | null) => void
): () => void {
const unsubs: Array<() => void> = [] const unsubs: Array<() => void> = []
// ─── chat.ts `streaming`: false → true edge triggers `thinking` ────── // Everything below is re-bound every time focus moves to a different
let lastStreaming = false // task window (or away from one entirely) — teardownSession tears down
let prevStreamValue: boolean | null = null // the previous session's bundle before (re)building for the new one.
// Hold the reaction while streaming stays true: we re-emit on each let teardownSession: (() => void) | null = null
// false→true edge so a new turn restarts the thinking anim.
unsubs.push( unsubs.push(
streaming.subscribe((s) => { focusedSessionId.subscribe((sid) => {
if (prevStreamValue === false && s === true) { teardownSession?.()
tryDispatch(REACTIONS.thinking, emit) teardownSession = null
} setBusy(null)
prevStreamValue = s if (!sid) return
lastStreaming = s
})
)
// Touch lastStreaming so the linter doesn't complain; it's used to
// reason about the edge detection above (kept for future "held while
// true" logic).
void lastStreaming
// ─── activity.ts `activityLog`: new `type === 'knowledge'` entry ───── const inner: Array<() => void> = []
// The store is derived and recomputed wholesale on every emission — const chat = chatFor(sid)
// NOT append-only — so detect new entries by diffing entry ids const ws = workspaceFor(sid)
// against the last-seen set. const log = activityLogFor(sid)
let prevKnowledgeIds = new Set<string>()
let firstActivityEmission = true
unsubs.push(
activityLog.subscribe((entries) => {
const currentIds = new Set<string>()
for (const e of entries) {
currentIds.add(e.id)
if (e.type === 'knowledge' && !prevKnowledgeIds.has(e.id) && !firstActivityEmission) {
tryDispatch(REACTIONS.eureka, emit)
}
}
prevKnowledgeIds = currentIds
firstActivityEmission = false
})
)
// ─── events.ts `liveEvents`: new head event ────────────────────────── // ─── Busy: thinking (composing, no text yet) vs talking (text is
// On the very first emission, just record the head event id — do NOT // streaming out) — see the `busy` BehaviorDef in behavior.ts. ──────
// replay history as reactions on mount. inner.push(
let lastSeenEventId = 0 derived([chat.streaming, chat.messages], ([s, msgs]) => {
let firstEventEmission = true if (!s) return null
unsubs.push( const last = msgs[msgs.length - 1]
liveEvents.subscribe((events) => { return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking'
const head = events[0] }).subscribe(setBusy)
if (!head) return )
if (head.id <= lastSeenEventId) return
lastSeenEventId = head.id // ─── Eureka (new knowledge) / Happy (task completed successfully) —
if (firstEventEmission) { // both derived from the session's own activity log. The store
firstEventEmission = false // recomputes wholesale on every emission (not append-only), so new
return // entries are detected by diffing ids against the last-seen set.
} // The first emission after (re)subscribing is never replayed as
if (head.severity === 'critical' || head.type.startsWith('signal.')) { // reactions — switching focus to an already-in-progress or already-
tryDispatch(REACTIONS.alarmed, emit) // done task shouldn't retroactively fire pulses for old entries. ──
} else if (head.type.startsWith('execution.')) { let seenIds = new Set<string>()
// Success-ish execution event — happy reaction. let firstLogEmission = true
tryDispatch(REACTIONS.happy, emit) inner.push(
log.subscribe((entries) => {
const nextIds = new Set<string>()
for (const e of entries) {
nextIds.add(e.id)
if (firstLogEmission || seenIds.has(e.id)) continue
if (e.type === 'knowledge') {
tryDispatch(REACTIONS.eureka, emit, `💡 ${truncate(e.description.replace(/^Recorded: /, ''))}`)
} else if (e.type === 'complete' && e.status !== 'failed') {
tryDispatch(REACTIONS.happy, emit, `🎉 ${truncate(e.description)}`)
}
}
seenIds = nextIds
firstLogEmission = false
})
)
// ─── Alarmed: a new operator question was raised (permission
// needed) — null -> non-null edge, same first-emission skip as
// above (focusing a task that already has a pending question
// shouldn't itself re-pulse the reaction). ──────────────────────
let hadQuestion = false
let firstQuestionEmission = true
inner.push(
ws.openQuestion.subscribe((q) => {
if (!firstQuestionEmission && q && !hadQuestion) {
tryDispatch(REACTIONS.alarmed, emit)
}
hadQuestion = q !== null
firstQuestionEmission = false
})
)
teardownSession = () => {
for (const u of inner) u()
} }
}) })
) )
unsubs.push(() => teardownSession?.())
// ─── SSE stream: ref-counted via subscribeEvents() ──────────────────
unsubs.push(subscribeEvents())
return () => { return () => {
for (const u of unsubs) u() for (const u of unsubs) u()
@@ -166,7 +194,7 @@ export function attachStimuli(emit: (r: ReactionDef) => void): () => void {
} }
/** Cooldown + priority gate before handing the reaction to MascotLayer. */ /** Cooldown + priority gate before handing the reaction to MascotLayer. */
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void { function tryDispatch(r: ReactionDef, emit: (r: ReactionDef, bubbleOverride?: string) => void, bubbleOverride?: string): void {
const now = performance.now() const now = performance.now()
const last = lastFired.get(r.id) ?? 0 const last = lastFired.get(r.id) ?? 0
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
@@ -177,7 +205,7 @@ function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void {
lastFired.set(r.id, now) lastFired.set(r.id, now)
currentReactPriority = r.priority currentReactPriority = r.priority
currentReactUntil = now + r.durationMs currentReactUntil = now + r.durationMs
emit(r) emit(r, bubbleOverride)
} }
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */ /** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
@@ -186,6 +214,3 @@ export function resetStimuliState(): void {
currentReactPriority = 0 currentReactPriority = 0
currentReactUntil = 0 currentReactUntil = 0
} }
// Type re-export so consumers don't need to import from events.ts separately.
export type { OikosEvent, ActivityEntry }

View File

@@ -11,7 +11,7 @@ export type MascotStage = 'egg' | 'chick' | 'adult'
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */ /** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
export type AnimName = export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch' | 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'talk'
| 'dragged' | 'fall-flutter' | 'land' | 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy' | 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
@@ -41,7 +41,7 @@ export interface AnimDef {
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */ /** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
export type BehaviorId = export type BehaviorId =
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep' | 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
| 'dragged' | 'falling' | 'land' | 'react' | 'dragged' | 'falling' | 'land' | 'react' | 'busy'
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */ /** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
export type Stimulus = string export type Stimulus = string
@@ -158,4 +158,12 @@ export interface MascotRuntime {
* Always false outside that one moment. * Always false outside that one moment.
*/ */
investigateOnLand: boolean investigateOnLand: boolean
/**
* When behavior === 'busy': which phase of the focused task's active
* turn to render — true plays the 'talk' sprite (text is streaming
* out), false plays 'react-think' (computing, nothing written yet).
* Set directly by MascotLayer's busy-state callback (see stimuli.ts);
* read each tick by the `busy` BehaviorDef's anim() in behavior.ts.
*/
busyTalking: boolean
} }

View File

@@ -3,12 +3,19 @@
// opened from Knowledge Base, chat, or anywhere else land in the same // opened from Knowledge Base, chat, or anywhere else land in the same
// floating window layer, with several windows open side by side, rather than // floating window layer, with several windows open side by side, rather than
// each page owning its own single-entity sidebar/sheet. // each page owning its own single-entity sidebar/sheet.
import { derived, type Readable } from 'svelte/store'
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte' import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist' import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps' import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat' import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks' import { heading } from '$lib/tasks'
// Window ids for a task/session's chat window are namespaced `session:<id>`
// — see openTaskWindow below. Shared here (rather than each file redeclaring
// its own copy) since both WindowLayer.svelte and focusedSessionId below
// need to parse it.
export const SESSION_PREFIX = 'session:'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } }) export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm, { export const dk = createDesktop(wm, {
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes // topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
@@ -23,6 +30,15 @@ export const dk = createDesktop(wm, {
}) })
export const wmState = wmStore(wm) export const wmState = wmStore(wm)
// The session id backing whichever task/chat window currently has focus, or
// null when no task window is focused (Tasks app, an entity window, or
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
// its reactions track the task the operator is actually looking at, rather
// than firing for every session fleet-wide.
export const focusedSessionId: Readable<string | null> = derived(wmState, ($s) =>
$s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null
)
// Layout survives reloads: every window id is self-describing (app:<id>, // Layout survives reloads: every window id is self-describing (app:<id>,
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's // session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
// content branch), so a hydrated window needs no extra bookkeeping to know // content branch), so a hydrated window needs no extra bookkeeping to know