feat(web): operator questions inline in chat, mascot reactions scoped to the focused task
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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (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<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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user