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 Spinner from './Spinner.svelte'
import ToolCallCard from './ToolCallCard.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
@@ -19,6 +20,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { SessionQuestion } from '$lib/api'
let {
messages,
@@ -31,7 +33,9 @@
onReconnect,
onDismissError,
suggestions = [],
activityLog: activityLogProp = activityLog
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null
}: {
messages: ChatMessage[]
streaming: boolean
@@ -44,6 +48,10 @@
onDismissError: (id: string) => void
suggestions?: string[]
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()
let input = $state('')
@@ -121,9 +129,11 @@
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(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
@@ -246,6 +256,9 @@
{/if}
</div>
{/each}
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div bind:this={messagesEnd}></div>
</div>
</div>

View File

@@ -26,7 +26,7 @@
{#if 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">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
<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
// there's something in it — a brand-new task otherwise opens to an empty
// "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
// shown from then on (no flicker back to hidden if e.g. touched entities
// later expire).
// list. Show it the moment either has real content, and keep it shown
// from then on (no flicker back to hidden if e.g. touched entities later
// 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)
$effect(() => {
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0 || $openQuestion !== null)) {
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
hasContext = true
}
})
@@ -91,6 +93,8 @@
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
@@ -109,6 +113,8 @@
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { startWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { startWorkspace, planSteps, currentTask, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte'
import UnifiedTimeline from './UnifiedTimeline.svelte'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
@@ -26,7 +25,6 @@
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
@@ -34,9 +32,6 @@
const streamingStore = $derived(chat ? chat.streaming : streaming)
const messagesStore = $derived(chat ? chat.messages : messages)
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 activityOpen = $state(true)
@@ -73,8 +68,6 @@
</script>
<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">
<!-- Scope -->
<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)
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
// 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 EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
@@ -18,8 +18,6 @@
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const SESSION_PREFIX = 'session:'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank

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()
})

View File

@@ -183,7 +183,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{
id: '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) => {
triggerReaction(ctx, 'thinking')
ctx.refresh()
@@ -192,7 +192,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{
id: '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) => {
triggerReaction(ctx, 'eureka')
ctx.refresh()
@@ -201,7 +201,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{
id: '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) => {
triggerReaction(ctx, 'alarmed')
ctx.refresh()
@@ -210,7 +210,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
{
id: '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) => {
triggerReaction(ctx, 'happy')
ctx.refresh()

View File

@@ -202,8 +202,12 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
id: 'idle',
// Periodic blink: enter() sets blinkUntil to the START of the next
// 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) => {
if (rt.bubbleText) return 'talk'
const now = performance.now()
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
return 'idle'
@@ -353,6 +357,27 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
next: () => 'idle',
minMs: 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 },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, 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 },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
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 },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, 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 },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
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:
// 1. Add a `ReactionDef` entry to REACTIONS below.
// 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
// over REACTIONS — no engine change is needed for a new reaction.
//
// Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble. `dragged` always wins over any
// reaction; `sleep` is broken only when `interruptsSleep` is true.
// durationMs}) and sets the bubble (optionally overridden per-dispatch —
// 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
// folds its unsubscribe into the returned teardown, so the mascot keeps
// the SSE stream open (ref-counted alongside any page that also
// subscribes) only while mounted.
// Scoping: everything below tracks whichever task/chat window currently has
// focus (windows.ts's focusedSessionId), re-bound every time focus moves —
// the mascot reacts to the task the operator is actually looking at, not to
// 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 { streaming } from '$lib/stores/chat'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import { derived } from 'svelte/store'
import { chatFor } from '$lib/stores/chat'
import { workspaceFor } from '$lib/stores/workspace'
import { activityLogFor } from '$lib/stores/activity'
import { focusedSessionId } from '$lib/stores/windows'
import { grantXp } from './state.svelte'
import type { AnimName } from './types'
@@ -25,7 +33,7 @@ export interface ReactionDef {
id: string
/** Animation to play while this reaction is active. */
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
/** Higher priority interrupts lower-priority reactions. */
priority: number
@@ -54,7 +62,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
anim: 'react-eureka',
bubble: '💡 Eureka!',
priority: 2,
cooldownMs: 10_000,
cooldownMs: 3000,
durationMs: 2200,
interruptsSleep: false,
effect: () => grantXp(5)
@@ -62,9 +70,9 @@ export const REACTIONS: Record<string, ReactionDef> = {
alarmed: {
id: 'alarmed',
anim: 'react-alarm',
bubble: '❗ Uh oh!',
bubble: '❗ Need your OK',
priority: 3,
cooldownMs: 15_000,
cooldownMs: 3000,
durationMs: 2500,
interruptsSleep: true
},
@@ -73,7 +81,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
anim: 'react-happy',
bubble: '🎉 Nice work!',
priority: 1,
cooldownMs: 20_000,
cooldownMs: 3000,
durationMs: 2000
}
}
@@ -85,80 +93,100 @@ const lastFired = new Map<string, number>()
let currentReactPriority = 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
* everything (including the SSE stream ref). The `emit` callback is the
* MascotLayer's bridge into the runtime — it decides whether to actually
* dispatch based on the current behavior (dragged always wins).
* everything. `emit` is the MascotLayer's bridge for pulse reactions
* (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch);
* `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> = []
// ─── chat.ts `streaming`: false → true edge triggers `thinking` ──────
let lastStreaming = false
let prevStreamValue: boolean | null = null
// Hold the reaction while streaming stays true: we re-emit on each
// false→true edge so a new turn restarts the thinking anim.
// Everything below is re-bound every time focus moves to a different
// task window (or away from one entirely) — teardownSession tears down
// the previous session's bundle before (re)building for the new one.
let teardownSession: (() => void) | null = null
unsubs.push(
streaming.subscribe((s) => {
if (prevStreamValue === false && s === true) {
tryDispatch(REACTIONS.thinking, emit)
}
prevStreamValue = s
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
focusedSessionId.subscribe((sid) => {
teardownSession?.()
teardownSession = null
setBusy(null)
if (!sid) return
// ─── activity.ts `activityLog`: new `type === 'knowledge'` entry ─────
// The store is derived and recomputed wholesale on every emission —
// NOT append-only — so detect new entries by diffing entry ids
// against the last-seen set.
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
})
)
const inner: Array<() => void> = []
const chat = chatFor(sid)
const ws = workspaceFor(sid)
const log = activityLogFor(sid)
// ─── events.ts `liveEvents`: new head event ──────────────────────────
// On the very first emission, just record the head event id — do NOT
// replay history as reactions on mount.
let lastSeenEventId = 0
let firstEventEmission = true
unsubs.push(
liveEvents.subscribe((events) => {
const head = events[0]
if (!head) return
if (head.id <= lastSeenEventId) return
lastSeenEventId = head.id
if (firstEventEmission) {
firstEventEmission = false
return
}
if (head.severity === 'critical' || head.type.startsWith('signal.')) {
tryDispatch(REACTIONS.alarmed, emit)
} else if (head.type.startsWith('execution.')) {
// Success-ish execution event — happy reaction.
tryDispatch(REACTIONS.happy, emit)
// ─── Busy: thinking (composing, no text yet) vs talking (text is
// streaming out) — see the `busy` BehaviorDef in behavior.ts. ──────
inner.push(
derived([chat.streaming, chat.messages], ([s, msgs]) => {
if (!s) return null
const last = msgs[msgs.length - 1]
return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking'
}).subscribe(setBusy)
)
// ─── Eureka (new knowledge) / Happy (task completed successfully) —
// both derived from the session's own activity log. The store
// recomputes wholesale on every emission (not append-only), so new
// entries are detected by diffing ids against the last-seen set.
// The first emission after (re)subscribing is never replayed as
// reactions — switching focus to an already-in-progress or already-
// done task shouldn't retroactively fire pulses for old entries. ──
let seenIds = new Set<string>()
let firstLogEmission = true
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()
}
})
)
// ─── SSE stream: ref-counted via subscribeEvents() ──────────────────
unsubs.push(subscribeEvents())
unsubs.push(() => teardownSession?.())
return () => {
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. */
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 last = lastFired.get(r.id) ?? 0
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)
currentReactPriority = r.priority
currentReactUntil = now + r.durationMs
emit(r)
emit(r, bubbleOverride)
}
/** 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
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. */
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'talk'
| 'dragged' | 'fall-flutter' | 'land'
| '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. */
export type BehaviorId =
| '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. */
export type Stimulus = string
@@ -158,4 +158,12 @@ export interface MascotRuntime {
* Always false outside that one moment.
*/
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
// floating window layer, with several windows open side by side, rather than
// 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 { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat'
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 dk = createDesktop(wm, {
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
@@ -23,6 +30,15 @@ export const dk = createDesktop(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>,
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
// content branch), so a hydrated window needs no extra bookkeeping to know