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 { Textarea } from '$lib/components/ui/textarea'
|
||||||
import Spinner from './Spinner.svelte'
|
import Spinner from './Spinner.svelte'
|
||||||
import ToolCallCard from './ToolCallCard.svelte'
|
import ToolCallCard from './ToolCallCard.svelte'
|
||||||
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import type { ChatMessage } from '$lib/stores/chat'
|
import type { ChatMessage } from '$lib/stores/chat'
|
||||||
|
import type { SessionQuestion } from '$lib/api'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
messages,
|
messages,
|
||||||
@@ -31,7 +33,9 @@
|
|||||||
onReconnect,
|
onReconnect,
|
||||||
onDismissError,
|
onDismissError,
|
||||||
suggestions = [],
|
suggestions = [],
|
||||||
activityLog: activityLogProp = activityLog
|
activityLog: activityLogProp = activityLog,
|
||||||
|
sessionId = null,
|
||||||
|
question = null
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
@@ -44,6 +48,10 @@
|
|||||||
onDismissError: (id: string) => void
|
onDismissError: (id: string) => void
|
||||||
suggestions?: string[]
|
suggestions?: string[]
|
||||||
activityLog?: Readable<ActivityEntry[]>
|
activityLog?: Readable<ActivityEntry[]>
|
||||||
|
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
|
||||||
|
sessionId?: string | null
|
||||||
|
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
|
||||||
|
question?: SessionQuestion | null
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let input = $state('')
|
let input = $state('')
|
||||||
@@ -121,9 +129,11 @@
|
|||||||
scrolledUp = !isNearBottom()
|
scrolledUp = !isNearBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
|
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
|
||||||
|
// unless user scrolled up to read.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void messages
|
void messages
|
||||||
|
void question
|
||||||
if (streaming || !scrolledUp) {
|
if (streaming || !scrolledUp) {
|
||||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||||
}
|
}
|
||||||
@@ -246,6 +256,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if question}
|
||||||
|
<OperatorQuestion {sessionId} {question} />
|
||||||
|
{/if}
|
||||||
<div bind:this={messagesEnd}></div>
|
<div bind:this={messagesEnd}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
{#if question}
|
{#if question}
|
||||||
{@const q = question}
|
{@const q = question}
|
||||||
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
|
<div class="flex flex-col gap-2 rounded-2xl border border-warning/30 bg-warning/5 px-3.5 py-3">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
|
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -42,12 +42,14 @@
|
|||||||
// The context rail (Scope/Activity) is only worth its screen space once
|
// The context rail (Scope/Activity) is only worth its screen space once
|
||||||
// there's something in it — a brand-new task otherwise opens to an empty
|
// there's something in it — a brand-new task otherwise opens to an empty
|
||||||
// "entities appear here" placeholder next to an equally empty activity
|
// "entities appear here" placeholder next to an equally empty activity
|
||||||
// list. Show it the moment any of the three has real content, and keep it
|
// list. Show it the moment either has real content, and keep it shown
|
||||||
// shown from then on (no flicker back to hidden if e.g. touched entities
|
// from then on (no flicker back to hidden if e.g. touched entities later
|
||||||
// later expire).
|
// expire). An open question does NOT gate this anymore — it renders
|
||||||
|
// inline in the chat thread itself (see ChatThread's `question` prop
|
||||||
|
// below), not in this rail.
|
||||||
let hasContext = $state(false)
|
let hasContext = $state(false)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0 || $openQuestion !== null)) {
|
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
|
||||||
hasContext = true
|
hasContext = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -91,6 +93,8 @@
|
|||||||
error={$chatError}
|
error={$chatError}
|
||||||
chatErrors={$chatErrors}
|
chatErrors={$chatErrors}
|
||||||
activityLog={sessionActivityLog}
|
activityLog={sessionActivityLog}
|
||||||
|
{sessionId}
|
||||||
|
question={$openQuestion}
|
||||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||||
onCancel={() => cancelSessionStream(sessionId)}
|
onCancel={() => cancelSessionStream(sessionId)}
|
||||||
onReconnect={() => loadSessionChat(sessionId)}
|
onReconnect={() => loadSessionChat(sessionId)}
|
||||||
@@ -109,6 +113,8 @@
|
|||||||
error={$chatError}
|
error={$chatError}
|
||||||
chatErrors={$chatErrors}
|
chatErrors={$chatErrors}
|
||||||
activityLog={sessionActivityLog}
|
activityLog={sessionActivityLog}
|
||||||
|
{sessionId}
|
||||||
|
question={$openQuestion}
|
||||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||||
onCancel={() => cancelSessionStream(sessionId)}
|
onCancel={() => cancelSessionStream(sessionId)}
|
||||||
onReconnect={() => loadSessionChat(sessionId)}
|
onReconnect={() => loadSessionChat(sessionId)}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||||
import { startWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
import { startWorkspace, planSteps, currentTask, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
||||||
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
|
import { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
|
||||||
import SessionGraph from './SessionGraph.svelte'
|
import SessionGraph from './SessionGraph.svelte'
|
||||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
@@ -26,7 +25,6 @@
|
|||||||
|
|
||||||
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
||||||
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
|
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
|
||||||
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
|
|
||||||
const touchedStore = $derived(ws ? ws.touched : touched)
|
const touchedStore = $derived(ws ? ws.touched : touched)
|
||||||
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
||||||
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
||||||
@@ -34,9 +32,6 @@
|
|||||||
const streamingStore = $derived(chat ? chat.streaming : streaming)
|
const streamingStore = $derived(chat ? chat.streaming : streaming)
|
||||||
const messagesStore = $derived(chat ? chat.messages : messages)
|
const messagesStore = $derived(chat ? chat.messages : messages)
|
||||||
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
||||||
// OperatorQuestion posts its answer against this id — the window's own
|
|
||||||
// session when set, otherwise whatever the main page currently has open.
|
|
||||||
const effectiveSessionId = $derived(sessionId ?? $currentSession)
|
|
||||||
|
|
||||||
let scopeOpen = $state(true)
|
let scopeOpen = $state(true)
|
||||||
let activityOpen = $state(true)
|
let activityOpen = $state(true)
|
||||||
@@ -73,8 +68,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
|
|
||||||
|
|
||||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||||
<!-- Scope -->
|
<!-- Scope -->
|
||||||
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
||||||
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
|
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
|
||||||
// anything else -> entity slug -> EntityDetailContent
|
// anything else -> entity slug -> EntityDetailContent
|
||||||
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
|
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID, SESSION_PREFIX } from '$lib/stores/windows'
|
||||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||||
@@ -18,8 +18,6 @@
|
|||||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||||
|
|
||||||
const SESSION_PREFIX = 'session:'
|
|
||||||
|
|
||||||
// A hydrated `app:<id>` window whose id no longer matches any registry
|
// A hydrated `app:<id>` window whose id no longer matches any registry
|
||||||
// entry (the app was renamed/removed since the layout was persisted) has
|
// entry (the app was renamed/removed since the layout was persisted) has
|
||||||
// nothing to render — close it rather than leaving a permanently-blank
|
// nothing to render — close it rather than leaving a permanently-blank
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
squashAt: 0,
|
squashAt: 0,
|
||||||
impactVy: 0,
|
impactVy: 0,
|
||||||
bounceCount: 0,
|
bounceCount: 0,
|
||||||
investigateOnLand: false
|
investigateOnLand: false,
|
||||||
|
busyTalking: false
|
||||||
})
|
})
|
||||||
|
|
||||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||||
@@ -147,33 +148,56 @@
|
|||||||
nameDialogMode = 'hatch'
|
nameDialogMode = 'hatch'
|
||||||
nameDialogOpen = true
|
nameDialogOpen = true
|
||||||
}
|
}
|
||||||
// Attach the stimulus bus (chat/activity/events -> reactions).
|
// Attach the stimulus bus (chat/activity/events -> reactions + the
|
||||||
// Reactions are gated to non-egg stages: the egg isn't "alive" yet
|
// continuous "busy" state). Both are gated to non-egg stages: the egg
|
||||||
// (no name, no hatched chick to react), so stimulus events are
|
// isn't "alive" yet (no name, no hatched chick to react), so stimulus
|
||||||
// silently dropped until the egg hatches. This keeps the egg calm
|
// events are silently dropped until the egg hatches. This keeps the
|
||||||
// during the naming dialog rather than playing alarm animations
|
// egg calm during the naming dialog rather than playing alarm
|
||||||
// behind it.
|
// animations behind it.
|
||||||
const detach = attachStimuli((reaction) => {
|
const detach = attachStimuli(
|
||||||
if (getModel().stage === 'egg') return
|
(reaction, bubbleOverride) => {
|
||||||
// Dragging always wins — never let a reaction interrupt an active
|
if (getModel().stage === 'egg') return
|
||||||
// drag (previously it could visually flash a reaction animation
|
// Dragging always wins — never let a reaction interrupt an active
|
||||||
// mid-drag, even though position tracking stayed correct; see the
|
// drag (previously it could visually flash a reaction animation
|
||||||
// physics audit's Finding 5). Sleep is only interrupted by
|
// mid-drag, even though position tracking stayed correct; see the
|
||||||
// reactions that explicitly opt in via interruptsSleep.
|
// physics audit's Finding 5). Sleep is only interrupted by
|
||||||
if (runtime.behavior === 'dragged') return
|
// reactions that explicitly opt in via interruptsSleep.
|
||||||
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
|
if (runtime.behavior === 'dragged') return
|
||||||
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
|
if (runtime.behavior === 'sleep' && !reaction.interruptsSleep) return
|
||||||
// here we just force the behavior.
|
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
|
||||||
const anim = reaction.anim as AnimName
|
// here we just force the behavior.
|
||||||
const id: BehaviorId = 'react'
|
const anim = reaction.anim as AnimName
|
||||||
runtime.reactAnim = anim
|
const id: BehaviorId = 'react'
|
||||||
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
|
runtime.reactAnim = anim
|
||||||
if (reaction.bubble) {
|
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
|
||||||
runtime.bubbleText = reaction.bubble
|
const bubble = bubbleOverride ?? reaction.bubble
|
||||||
runtime.bubbleUntil = performance.now() + reaction.durationMs
|
if (bubble) {
|
||||||
|
runtime.bubbleText = bubble
|
||||||
|
runtime.bubbleUntil = performance.now() + reaction.durationMs
|
||||||
|
}
|
||||||
|
if (reaction.effect) reaction.effect()
|
||||||
|
},
|
||||||
|
(phase) => {
|
||||||
|
// Continuous engagement with the focused task's active turn — see
|
||||||
|
// stimuli.ts. Not a timed pulse: stays in `busy` until stimuli.ts
|
||||||
|
// reports the turn ended (phase === null), same drag/sleep gating
|
||||||
|
// as reactions above. Also never preempts an in-flight reaction
|
||||||
|
// pulse (eureka/alarmed/happy) — those are short and should play
|
||||||
|
// out; the next chat update re-affirms busy shortly after (the
|
||||||
|
// underlying store keeps emitting throughout an active turn), so
|
||||||
|
// this self-heals within a token or two rather than needing an
|
||||||
|
// explicit "resume busy after this pulse" handoff.
|
||||||
|
if (getModel().stage === 'egg') return
|
||||||
|
if (runtime.behavior === 'dragged' || runtime.behavior === 'react') return
|
||||||
|
if (phase === null) {
|
||||||
|
if (runtime.behavior === 'busy') forceBehavior(runtime, 'idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (runtime.behavior === 'sleep') return
|
||||||
|
runtime.busyTalking = phase === 'talking'
|
||||||
|
if (runtime.behavior !== 'busy') forceBehavior(runtime, 'busy')
|
||||||
}
|
}
|
||||||
if (reaction.effect) reaction.effect()
|
)
|
||||||
})
|
|
||||||
return () => detach()
|
return () => detach()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'trigger-thinking',
|
id: 'trigger-thinking',
|
||||||
label: 'Trigger: Thinking',
|
label: 'Trigger: Thinking',
|
||||||
description: 'Normally fires when Nomos starts streaming a reply in any open chat window.',
|
description: 'Normally a continuous state (not a timed pulse like this button): the focused task window\'s session starts streaming a reply, before any text has arrived yet. Switches to the "talk" sprite the moment text starts appearing.',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
triggerReaction(ctx, 'thinking')
|
triggerReaction(ctx, 'thinking')
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
@@ -192,7 +192,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'trigger-eureka',
|
id: 'trigger-eureka',
|
||||||
label: 'Trigger: Eureka',
|
label: 'Trigger: Eureka',
|
||||||
description: 'Normally fires when a new knowledge-graph entry is written (an "upsert_knowledge" tool result). Grants +5 xp.',
|
description: 'Normally fires when the focused task window records new knowledge (an "upsert_knowledge" tool result) — the real bubble shows the knowledge\'s own title. Grants +5 xp.',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
triggerReaction(ctx, 'eureka')
|
triggerReaction(ctx, 'eureka')
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
@@ -201,7 +201,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'trigger-alarmed',
|
id: 'trigger-alarmed',
|
||||||
label: 'Trigger: Alarmed',
|
label: 'Trigger: Alarmed',
|
||||||
description: 'Normally fires on a critical event or a new signal.* event on the live event stream. The only reaction that wakes the mascot from sleep.',
|
description: 'Normally fires when the focused task window raises a new operator question (permission needed to proceed). The only reaction that wakes the mascot from sleep.',
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
triggerReaction(ctx, 'alarmed')
|
triggerReaction(ctx, 'alarmed')
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
@@ -210,7 +210,7 @@ export const MASCOT_ACTIONS: RadialAction[] = [
|
|||||||
{
|
{
|
||||||
id: 'trigger-happy',
|
id: 'trigger-happy',
|
||||||
label: 'Trigger: Happy',
|
label: 'Trigger: Happy',
|
||||||
description: 'Normally fires when an execution.* event lands on the live event stream.',
|
description: "Normally fires when the focused task window's task completes successfully — the real bubble shows the task's own summary.",
|
||||||
action: (ctx) => {
|
action: (ctx) => {
|
||||||
triggerReaction(ctx, 'happy')
|
triggerReaction(ctx, 'happy')
|
||||||
ctx.refresh()
|
ctx.refresh()
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
id: 'idle',
|
id: 'idle',
|
||||||
// Periodic blink: enter() sets blinkUntil to the START of the next
|
// Periodic blink: enter() sets blinkUntil to the START of the next
|
||||||
// blink window (2–6s away). anim() returns 'blink' when we're past
|
// 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) => {
|
anim: (rt) => {
|
||||||
|
if (rt.bubbleText) return 'talk'
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
|
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
|
||||||
return 'idle'
|
return 'idle'
|
||||||
@@ -353,6 +357,27 @@ export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
|
|||||||
next: () => 'idle',
|
next: () => 'idle',
|
||||||
minMs: REACT_DEFAULT_MS,
|
minMs: REACT_DEFAULT_MS,
|
||||||
maxMs: REACT_DEFAULT_MS
|
maxMs: REACT_DEFAULT_MS
|
||||||
|
},
|
||||||
|
|
||||||
|
// Continuous engagement with the focused task window's active turn — not
|
||||||
|
// a timed pulse like `react` above. Entered/exited directly by
|
||||||
|
// MascotLayer's busy-state callback (see stimuli.ts's attachStimuli,
|
||||||
|
// second callback), which also keeps rt.busyTalking current every time
|
||||||
|
// the phase flips. No `weight` — never auto-picked by the idle selector,
|
||||||
|
// same as dragged/falling/land.
|
||||||
|
busy: {
|
||||||
|
id: 'busy',
|
||||||
|
anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'),
|
||||||
|
tick: () => {
|
||||||
|
// Stationary — just displays whichever sprite busyTalking selects.
|
||||||
|
},
|
||||||
|
// Only reached if something calls next() on it directly, which nothing
|
||||||
|
// does in practice: MascotLayer forces 'idle' itself the moment
|
||||||
|
// stimuli.ts reports the turn ended. Falling back to 'idle' here is
|
||||||
|
// just a safe default, not the real exit path.
|
||||||
|
next: () => 'idle',
|
||||||
|
minMs: 0,
|
||||||
|
maxMs: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> =
|
|||||||
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
|
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
|
||||||
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
||||||
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
|
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
|
||||||
|
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
|
||||||
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
||||||
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
|
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
|
||||||
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
|
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
|
||||||
@@ -54,6 +55,7 @@ export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> =
|
|||||||
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
|
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
|
||||||
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
||||||
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
|
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
|
||||||
|
talk: { src: '/mascot/peep.png', frames: 2, fps: 6, loop: true },
|
||||||
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
|
||||||
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
|
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
|
||||||
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
|
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
// Stimulus / reaction system. To add a new environment reaction:
|
// Stimulus / reaction system. To add a new environment reaction:
|
||||||
// 1. Add a `ReactionDef` entry to REACTIONS below.
|
// 1. Add a `ReactionDef` entry to REACTIONS below.
|
||||||
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
|
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
|
||||||
// inside `attachStimuli()`.
|
// inside `attachStimuli()`'s per-session bundle.
|
||||||
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
|
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
|
||||||
// over REACTIONS — no engine change is needed for a new reaction.
|
// over REACTIONS — no engine change is needed for a new reaction.
|
||||||
//
|
//
|
||||||
// Reactions are dispatched into the MascotLayer via the `emit` callback
|
// Reactions are dispatched into the MascotLayer via the `emit` callback
|
||||||
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
|
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
|
||||||
// durationMs}) and sets the bubble. `dragged` always wins over any
|
// durationMs}) and sets the bubble (optionally overridden per-dispatch —
|
||||||
// reaction; `sleep` is broken only when `interruptsSleep` is true.
|
// see the `bubbleOverride` param — so e.g. eureka can show the actual
|
||||||
|
// knowledge title instead of a generic line). `dragged` always wins over
|
||||||
|
// any reaction; `sleep` is broken only when `interruptsSleep` is true.
|
||||||
//
|
//
|
||||||
// `attachStimuli` owns the SSE subscription (via subscribeEvents()) and
|
// Scoping: everything below tracks whichever task/chat window currently has
|
||||||
// folds its unsubscribe into the returned teardown, so the mascot keeps
|
// focus (windows.ts's focusedSessionId), re-bound every time focus moves —
|
||||||
// the SSE stream open (ref-counted alongside any page that also
|
// the mascot reacts to the task the operator is actually looking at, not to
|
||||||
// subscribes) only while mounted.
|
// every session fleet-wide. Nothing fires while no task window is focused.
|
||||||
|
//
|
||||||
|
// `thinking`/`talking` aren't pulse reactions at all: they're the
|
||||||
|
// continuous `busy` FSM behavior (behavior.ts), driven by the second
|
||||||
|
// `setBusy` callback rather than `emit` — see the "Busy" block below.
|
||||||
|
|
||||||
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
import { derived } from 'svelte/store'
|
||||||
import { streaming } from '$lib/stores/chat'
|
import { chatFor } from '$lib/stores/chat'
|
||||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
import { workspaceFor } from '$lib/stores/workspace'
|
||||||
|
import { activityLogFor } from '$lib/stores/activity'
|
||||||
|
import { focusedSessionId } from '$lib/stores/windows'
|
||||||
import { grantXp } from './state.svelte'
|
import { grantXp } from './state.svelte'
|
||||||
import type { AnimName } from './types'
|
import type { AnimName } from './types'
|
||||||
|
|
||||||
@@ -25,7 +33,7 @@ export interface ReactionDef {
|
|||||||
id: string
|
id: string
|
||||||
/** Animation to play while this reaction is active. */
|
/** Animation to play while this reaction is active. */
|
||||||
anim: AnimName
|
anim: AnimName
|
||||||
/** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). */
|
/** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). Per-dispatch callers may override this — see `tryDispatch`'s bubbleOverride param. */
|
||||||
bubble?: string
|
bubble?: string
|
||||||
/** Higher priority interrupts lower-priority reactions. */
|
/** Higher priority interrupts lower-priority reactions. */
|
||||||
priority: number
|
priority: number
|
||||||
@@ -54,7 +62,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
anim: 'react-eureka',
|
anim: 'react-eureka',
|
||||||
bubble: '💡 Eureka!',
|
bubble: '💡 Eureka!',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
cooldownMs: 10_000,
|
cooldownMs: 3000,
|
||||||
durationMs: 2200,
|
durationMs: 2200,
|
||||||
interruptsSleep: false,
|
interruptsSleep: false,
|
||||||
effect: () => grantXp(5)
|
effect: () => grantXp(5)
|
||||||
@@ -62,9 +70,9 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
alarmed: {
|
alarmed: {
|
||||||
id: 'alarmed',
|
id: 'alarmed',
|
||||||
anim: 'react-alarm',
|
anim: 'react-alarm',
|
||||||
bubble: '❗ Uh oh!',
|
bubble: '❗ Need your OK',
|
||||||
priority: 3,
|
priority: 3,
|
||||||
cooldownMs: 15_000,
|
cooldownMs: 3000,
|
||||||
durationMs: 2500,
|
durationMs: 2500,
|
||||||
interruptsSleep: true
|
interruptsSleep: true
|
||||||
},
|
},
|
||||||
@@ -73,7 +81,7 @@ export const REACTIONS: Record<string, ReactionDef> = {
|
|||||||
anim: 'react-happy',
|
anim: 'react-happy',
|
||||||
bubble: '🎉 Nice work!',
|
bubble: '🎉 Nice work!',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
cooldownMs: 20_000,
|
cooldownMs: 3000,
|
||||||
durationMs: 2000
|
durationMs: 2000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,80 +93,100 @@ const lastFired = new Map<string, number>()
|
|||||||
let currentReactPriority = 0
|
let currentReactPriority = 0
|
||||||
let currentReactUntil = 0
|
let currentReactUntil = 0
|
||||||
|
|
||||||
|
const BUBBLE_MAX = 70
|
||||||
|
|
||||||
|
/** Truncate a dynamic bubble line (a knowledge title, a task summary) to something that still fits the speech bubble. */
|
||||||
|
function truncate(s: string, max = BUBBLE_MAX): string {
|
||||||
|
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach all stimulus subscriptions. Returns a teardown that detaches
|
* Attach all stimulus subscriptions. Returns a teardown that detaches
|
||||||
* everything (including the SSE stream ref). The `emit` callback is the
|
* everything. `emit` is the MascotLayer's bridge for pulse reactions
|
||||||
* MascotLayer's bridge into the runtime — it decides whether to actually
|
* (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch);
|
||||||
* dispatch based on the current behavior (dragged always wins).
|
* `setBusy` is its bridge for the continuous thinking/talking state (no
|
||||||
|
* gating — it's a live phase, not a discrete event).
|
||||||
*/
|
*/
|
||||||
export function attachStimuli(emit: (r: ReactionDef) => void): () => void {
|
export function attachStimuli(
|
||||||
|
emit: (r: ReactionDef, bubbleOverride?: string) => void,
|
||||||
|
setBusy: (phase: 'thinking' | 'talking' | null) => void
|
||||||
|
): () => void {
|
||||||
const unsubs: Array<() => void> = []
|
const unsubs: Array<() => void> = []
|
||||||
|
|
||||||
// ─── chat.ts `streaming`: false → true edge triggers `thinking` ──────
|
// Everything below is re-bound every time focus moves to a different
|
||||||
let lastStreaming = false
|
// task window (or away from one entirely) — teardownSession tears down
|
||||||
let prevStreamValue: boolean | null = null
|
// the previous session's bundle before (re)building for the new one.
|
||||||
// Hold the reaction while streaming stays true: we re-emit on each
|
let teardownSession: (() => void) | null = null
|
||||||
// false→true edge so a new turn restarts the thinking anim.
|
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
streaming.subscribe((s) => {
|
focusedSessionId.subscribe((sid) => {
|
||||||
if (prevStreamValue === false && s === true) {
|
teardownSession?.()
|
||||||
tryDispatch(REACTIONS.thinking, emit)
|
teardownSession = null
|
||||||
}
|
setBusy(null)
|
||||||
prevStreamValue = s
|
if (!sid) return
|
||||||
lastStreaming = s
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// Touch lastStreaming so the linter doesn't complain; it's used to
|
|
||||||
// reason about the edge detection above (kept for future "held while
|
|
||||||
// true" logic).
|
|
||||||
void lastStreaming
|
|
||||||
|
|
||||||
// ─── activity.ts `activityLog`: new `type === 'knowledge'` entry ─────
|
const inner: Array<() => void> = []
|
||||||
// The store is derived and recomputed wholesale on every emission —
|
const chat = chatFor(sid)
|
||||||
// NOT append-only — so detect new entries by diffing entry ids
|
const ws = workspaceFor(sid)
|
||||||
// against the last-seen set.
|
const log = activityLogFor(sid)
|
||||||
let prevKnowledgeIds = new Set<string>()
|
|
||||||
let firstActivityEmission = true
|
|
||||||
unsubs.push(
|
|
||||||
activityLog.subscribe((entries) => {
|
|
||||||
const currentIds = new Set<string>()
|
|
||||||
for (const e of entries) {
|
|
||||||
currentIds.add(e.id)
|
|
||||||
if (e.type === 'knowledge' && !prevKnowledgeIds.has(e.id) && !firstActivityEmission) {
|
|
||||||
tryDispatch(REACTIONS.eureka, emit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevKnowledgeIds = currentIds
|
|
||||||
firstActivityEmission = false
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── events.ts `liveEvents`: new head event ──────────────────────────
|
// ─── Busy: thinking (composing, no text yet) vs talking (text is
|
||||||
// On the very first emission, just record the head event id — do NOT
|
// streaming out) — see the `busy` BehaviorDef in behavior.ts. ──────
|
||||||
// replay history as reactions on mount.
|
inner.push(
|
||||||
let lastSeenEventId = 0
|
derived([chat.streaming, chat.messages], ([s, msgs]) => {
|
||||||
let firstEventEmission = true
|
if (!s) return null
|
||||||
unsubs.push(
|
const last = msgs[msgs.length - 1]
|
||||||
liveEvents.subscribe((events) => {
|
return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking'
|
||||||
const head = events[0]
|
}).subscribe(setBusy)
|
||||||
if (!head) return
|
)
|
||||||
if (head.id <= lastSeenEventId) return
|
|
||||||
lastSeenEventId = head.id
|
// ─── Eureka (new knowledge) / Happy (task completed successfully) —
|
||||||
if (firstEventEmission) {
|
// both derived from the session's own activity log. The store
|
||||||
firstEventEmission = false
|
// recomputes wholesale on every emission (not append-only), so new
|
||||||
return
|
// entries are detected by diffing ids against the last-seen set.
|
||||||
}
|
// The first emission after (re)subscribing is never replayed as
|
||||||
if (head.severity === 'critical' || head.type.startsWith('signal.')) {
|
// reactions — switching focus to an already-in-progress or already-
|
||||||
tryDispatch(REACTIONS.alarmed, emit)
|
// done task shouldn't retroactively fire pulses for old entries. ──
|
||||||
} else if (head.type.startsWith('execution.')) {
|
let seenIds = new Set<string>()
|
||||||
// Success-ish execution event — happy reaction.
|
let firstLogEmission = true
|
||||||
tryDispatch(REACTIONS.happy, emit)
|
inner.push(
|
||||||
|
log.subscribe((entries) => {
|
||||||
|
const nextIds = new Set<string>()
|
||||||
|
for (const e of entries) {
|
||||||
|
nextIds.add(e.id)
|
||||||
|
if (firstLogEmission || seenIds.has(e.id)) continue
|
||||||
|
if (e.type === 'knowledge') {
|
||||||
|
tryDispatch(REACTIONS.eureka, emit, `💡 ${truncate(e.description.replace(/^Recorded: /, ''))}`)
|
||||||
|
} else if (e.type === 'complete' && e.status !== 'failed') {
|
||||||
|
tryDispatch(REACTIONS.happy, emit, `🎉 ${truncate(e.description)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenIds = nextIds
|
||||||
|
firstLogEmission = false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Alarmed: a new operator question was raised (permission
|
||||||
|
// needed) — null -> non-null edge, same first-emission skip as
|
||||||
|
// above (focusing a task that already has a pending question
|
||||||
|
// shouldn't itself re-pulse the reaction). ──────────────────────
|
||||||
|
let hadQuestion = false
|
||||||
|
let firstQuestionEmission = true
|
||||||
|
inner.push(
|
||||||
|
ws.openQuestion.subscribe((q) => {
|
||||||
|
if (!firstQuestionEmission && q && !hadQuestion) {
|
||||||
|
tryDispatch(REACTIONS.alarmed, emit)
|
||||||
|
}
|
||||||
|
hadQuestion = q !== null
|
||||||
|
firstQuestionEmission = false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
teardownSession = () => {
|
||||||
|
for (const u of inner) u()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
unsubs.push(() => teardownSession?.())
|
||||||
// ─── SSE stream: ref-counted via subscribeEvents() ──────────────────
|
|
||||||
unsubs.push(subscribeEvents())
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const u of unsubs) u()
|
for (const u of unsubs) u()
|
||||||
@@ -166,7 +194,7 @@ export function attachStimuli(emit: (r: ReactionDef) => void): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Cooldown + priority gate before handing the reaction to MascotLayer. */
|
/** Cooldown + priority gate before handing the reaction to MascotLayer. */
|
||||||
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void {
|
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef, bubbleOverride?: string) => void, bubbleOverride?: string): void {
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
const last = lastFired.get(r.id) ?? 0
|
const last = lastFired.get(r.id) ?? 0
|
||||||
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
|
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
|
||||||
@@ -177,7 +205,7 @@ function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void {
|
|||||||
lastFired.set(r.id, now)
|
lastFired.set(r.id, now)
|
||||||
currentReactPriority = r.priority
|
currentReactPriority = r.priority
|
||||||
currentReactUntil = now + r.durationMs
|
currentReactUntil = now + r.durationMs
|
||||||
emit(r)
|
emit(r, bubbleOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
|
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
|
||||||
@@ -186,6 +214,3 @@ export function resetStimuliState(): void {
|
|||||||
currentReactPriority = 0
|
currentReactPriority = 0
|
||||||
currentReactUntil = 0
|
currentReactUntil = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type re-export so consumers don't need to import from events.ts separately.
|
|
||||||
export type { OikosEvent, ActivityEntry }
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type MascotStage = 'egg' | 'chick' | 'adult'
|
|||||||
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
|
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
|
||||||
export type AnimName =
|
export type AnimName =
|
||||||
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
|
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
|
||||||
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
|
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep' | 'talk'
|
||||||
| 'dragged' | 'fall-flutter' | 'land'
|
| 'dragged' | 'fall-flutter' | 'land'
|
||||||
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
|
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export interface AnimDef {
|
|||||||
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
|
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
|
||||||
export type BehaviorId =
|
export type BehaviorId =
|
||||||
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
|
| 'egg' | 'idle' | 'wander' | 'peck' | 'hop' | 'sleep'
|
||||||
| 'dragged' | 'falling' | 'land' | 'react'
|
| 'dragged' | 'falling' | 'land' | 'react' | 'busy'
|
||||||
|
|
||||||
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
|
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
|
||||||
export type Stimulus = string
|
export type Stimulus = string
|
||||||
@@ -158,4 +158,12 @@ export interface MascotRuntime {
|
|||||||
* Always false outside that one moment.
|
* Always false outside that one moment.
|
||||||
*/
|
*/
|
||||||
investigateOnLand: boolean
|
investigateOnLand: boolean
|
||||||
|
/**
|
||||||
|
* When behavior === 'busy': which phase of the focused task's active
|
||||||
|
* turn to render — true plays the 'talk' sprite (text is streaming
|
||||||
|
* out), false plays 'react-think' (computing, nothing written yet).
|
||||||
|
* Set directly by MascotLayer's busy-state callback (see stimuli.ts);
|
||||||
|
* read each tick by the `busy` BehaviorDef's anim() in behavior.ts.
|
||||||
|
*/
|
||||||
|
busyTalking: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,19 @@
|
|||||||
// opened from Knowledge Base, chat, or anywhere else land in the same
|
// opened from Knowledge Base, chat, or anywhere else land in the same
|
||||||
// floating window layer, with several windows open side by side, rather than
|
// floating window layer, with several windows open side by side, rather than
|
||||||
// each page owning its own single-entity sidebar/sheet.
|
// each page owning its own single-entity sidebar/sheet.
|
||||||
|
import { derived, type Readable } from 'svelte/store'
|
||||||
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
|
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
|
||||||
import { persist } from '@surdeddd/wmkit/persist'
|
import { persist } from '@surdeddd/wmkit/persist'
|
||||||
import { appById, appWindowId } from '$lib/apps'
|
import { appById, appWindowId } from '$lib/apps'
|
||||||
import { sessions } from '$lib/stores/chat'
|
import { sessions } from '$lib/stores/chat'
|
||||||
import { heading } from '$lib/tasks'
|
import { heading } from '$lib/tasks'
|
||||||
|
|
||||||
|
// Window ids for a task/session's chat window are namespaced `session:<id>`
|
||||||
|
// — see openTaskWindow below. Shared here (rather than each file redeclaring
|
||||||
|
// its own copy) since both WindowLayer.svelte and focusedSessionId below
|
||||||
|
// need to parse it.
|
||||||
|
export const SESSION_PREFIX = 'session:'
|
||||||
|
|
||||||
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
|
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
|
||||||
export const dk = createDesktop(wm, {
|
export const dk = createDesktop(wm, {
|
||||||
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
|
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
|
||||||
@@ -23,6 +30,15 @@ export const dk = createDesktop(wm, {
|
|||||||
})
|
})
|
||||||
export const wmState = wmStore(wm)
|
export const wmState = wmStore(wm)
|
||||||
|
|
||||||
|
// The session id backing whichever task/chat window currently has focus, or
|
||||||
|
// null when no task window is focused (Tasks app, an entity window, or
|
||||||
|
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
|
||||||
|
// its reactions track the task the operator is actually looking at, rather
|
||||||
|
// than firing for every session fleet-wide.
|
||||||
|
export const focusedSessionId: Readable<string | null> = derived(wmState, ($s) =>
|
||||||
|
$s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null
|
||||||
|
)
|
||||||
|
|
||||||
// Layout survives reloads: every window id is self-describing (app:<id>,
|
// Layout survives reloads: every window id is self-describing (app:<id>,
|
||||||
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
|
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
|
||||||
// content branch), so a hydrated window needs no extra bookkeeping to know
|
// content branch), so a hydrated window needs no extra bookkeeping to know
|
||||||
|
|||||||
Reference in New Issue
Block a user