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