From e5a81241b758fc149856c780e222c80b7a2a3264 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 21 Jul 2026 10:46:46 +0200 Subject: [PATCH] feat(web): operator questions inline in chat, mascot reactions scoped to the focused task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/lib/components/ChatThread.svelte | 17 +- .../lib/components/OperatorQuestion.svelte | 2 +- .../lib/components/SessionChatWindow.svelte | 14 +- .../lib/components/TaskContextPanel.svelte | 11 +- .../desktop-shell/WindowLayer.svelte | 4 +- web/src/lib/mascot/MascotLayer.svelte | 78 ++++--- web/src/lib/mascot/actions.ts | 8 +- web/src/lib/mascot/behavior.ts | 27 ++- web/src/lib/mascot/sprites.ts | 2 + web/src/lib/mascot/stimuli.ts | 191 ++++++++++-------- web/src/lib/mascot/types.ts | 12 +- web/src/lib/stores/windows.ts | 16 ++ 12 files changed, 246 insertions(+), 136 deletions(-) diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index 142a790..1a96c70 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -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 + /** 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} {/each} + {#if question} + + {/if}
diff --git a/web/src/lib/components/OperatorQuestion.svelte b/web/src/lib/components/OperatorQuestion.svelte index 0229d9f..7207981 100644 --- a/web/src/lib/components/OperatorQuestion.svelte +++ b/web/src/lib/components/OperatorQuestion.svelte @@ -26,7 +26,7 @@ {#if question} {@const q = question} -
+
diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte index b8bfb6d..dd431f5 100644 --- a/web/src/lib/components/SessionChatWindow.svelte +++ b/web/src/lib/components/SessionChatWindow.svelte @@ -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)} diff --git a/web/src/lib/components/TaskContextPanel.svelte b/web/src/lib/components/TaskContextPanel.svelte index 72faece..163b81b 100644 --- a/web/src/lib/components/TaskContextPanel.svelte +++ b/web/src/lib/components/TaskContextPanel.svelte @@ -1,10 +1,9 @@
- - diff --git a/web/src/lib/components/desktop-shell/WindowLayer.svelte b/web/src/lib/components/desktop-shell/WindowLayer.svelte index 650508b..3976608 100644 --- a/web/src/lib/components/desktop-shell/WindowLayer.svelte +++ b/web/src/lib/components/desktop-shell/WindowLayer.svelte @@ -9,7 +9,7 @@ // session: -> 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:` 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 diff --git a/web/src/lib/mascot/MascotLayer.svelte b/web/src/lib/mascot/MascotLayer.svelte index a830cd9..cbfc53c 100644 --- a/web/src/lib/mascot/MascotLayer.svelte +++ b/web/src/lib/mascot/MascotLayer.svelte @@ -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() }) diff --git a/web/src/lib/mascot/actions.ts b/web/src/lib/mascot/actions.ts index 9d192d1..9b50331 100644 --- a/web/src/lib/mascot/actions.ts +++ b/web/src/lib/mascot/actions.ts @@ -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() diff --git a/web/src/lib/mascot/behavior.ts b/web/src/lib/mascot/behavior.ts index 5778551..5676318 100644 --- a/web/src/lib/mascot/behavior.ts +++ b/web/src/lib/mascot/behavior.ts @@ -202,8 +202,12 @@ export const BEHAVIORS: Record = { id: 'idle', // Periodic blink: enter() sets blinkUntil to the START of the next // blink window (2–6s 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 = { 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 } } diff --git a/web/src/lib/mascot/sprites.ts b/web/src/lib/mascot/sprites.ts index 6911647..120ec04 100644 --- a/web/src/lib/mascot/sprites.ts +++ b/web/src/lib/mascot/sprites.ts @@ -39,6 +39,7 @@ export const SPRITES: Record>> = 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>> = 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 }, diff --git a/web/src/lib/mascot/stimuli.ts b/web/src/lib/mascot/stimuli.ts index 60989e4..593f917 100644 --- a/web/src/lib/mascot/stimuli.ts +++ b/web/src/lib/mascot/stimuli.ts @@ -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 = { 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 = { 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 = { anim: 'react-happy', bubble: '🎉 Nice work!', priority: 1, - cooldownMs: 20_000, + cooldownMs: 3000, durationMs: 2000 } } @@ -85,80 +93,100 @@ const lastFired = new Map() 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() - let firstActivityEmission = true - unsubs.push( - activityLog.subscribe((entries) => { - const currentIds = new Set() - 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() + let firstLogEmission = true + inner.push( + log.subscribe((entries) => { + const nextIds = new Set() + 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 } diff --git a/web/src/lib/mascot/types.ts b/web/src/lib/mascot/types.ts index 61867eb..2d4cf6b 100644 --- a/web/src/lib/mascot/types.ts +++ b/web/src/lib/mascot/types.ts @@ -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 } diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts index 149eafa..967735e 100644 --- a/web/src/lib/stores/windows.ts +++ b/web/src/lib/stores/windows.ts @@ -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:` +// — 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 = 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:, // session:, or a bare entity slug — see EntityDesktop/WindowLayer's // content branch), so a hydrated window needs no extra bookkeeping to know