Activity timeline replaces ToolBubble

Tool calls in a turn now render as one ActivityTimeline block: expanded
with "Simba is working…" while running (icon, label, input summary,
spinner/✓/✗, duration, Open chip for artifacts), collapsing to
"Simba worked · N steps · 4.2 s" when the turn finishes. useChat keys tool
rows by run_id (parallel calls), synthesises ids for legacy servers that
send only {tool}, and restores rows from the conversation payload.

Adds src/api/artifactTypes.ts (shared with the artifact panel) and a
dependency-free mock API server under test-fixtures/cowork/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:31:06 -04:00
parent ce50979ff7
commit f85607d62d
7 changed files with 875 additions and 66 deletions
+37
View File
@@ -0,0 +1,37 @@
// Artifact types shared by the activity timeline and the artifact panel.
// Shapes follow the contract in docs/cowork-ui-plan.md.
export type ArtifactKind =
| "mealie.recipe"
| "mealie.meal_plan"
| "mealie.shopping_lists"
| "obsidian.note"
| "search.documents"
| "ynab.summary"
| "ynab.transactions"
| "ynab.spending_report"
| "ynab.category_budget";
/** Lightweight reference carried by `tool_end` events and tool messages. */
export type ArtifactRef = {
id: string;
kind: ArtifactKind;
title: string;
};
export type ArtifactSourceRef = {
system: "mealie" | "obsidian" | "paperless" | "ynab" | "web";
id?: string;
path?: string;
url?: string;
};
/** Full artifact as returned by `GET /api/artifacts/<id>`. */
export type Artifact = ArtifactRef & {
data: object;
source_ref: ArtifactSourceRef | null;
tool: string;
conversation_id: string;
message_id: string | null;
created_at: string;
};
+25 -2
View File
@@ -1,10 +1,29 @@
import { userService } from "./userService";
import type { ArtifactRef } from "./artifactTypes";
export type ToolInput = Record<string, string | number | boolean>;
export type SSEEvent =
| { type: "thinking" }
| { type: "thinking_delta"; delta: string }
| { type: "tool_start"; tool: string }
| { type: "tool_end"; tool: string }
// v2 servers send run_id/label/input and run_id/label/status/ms/artifact;
// older servers send only `tool`, so everything else is optional here.
| {
type: "tool_start";
tool: string;
run_id?: string;
label?: string;
input?: ToolInput;
}
| {
type: "tool_end";
tool: string;
run_id?: string;
label?: string;
status?: "ok" | "error";
ms?: number;
artifact?: ArtifactRef | null;
}
| { type: "content"; delta: string }
| { type: "response"; message: string }
| { type: "error"; message: string; stalled?: boolean };
@@ -17,6 +36,10 @@ interface Message {
speaker: "user" | "simba" | "tool";
created_at: string;
image_key?: string | null;
// Tool messages only (v2 servers).
tool?: string;
label?: string;
artifact?: ArtifactRef | null;
}
interface Conversation {
@@ -0,0 +1,239 @@
import React, { useEffect, useId, useState } from "react";
import {
BookOpen,
CalendarDays,
Cat,
Check,
ChefHat,
ChevronDown,
Clock,
Globe,
Library,
LoaderCircle,
Mail,
NotebookPen,
Package,
PanelRightOpen,
PawPrint,
Utensils,
Wallet,
Wrench,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
import type { ArtifactRef } from "../api/artifactTypes";
import type { ToolInput } from "../api/conversationService";
import type { ToolMessage } from "../hooks/useChat";
// Checked in order, so longer prefixes (simbakit_) come before shorter ones.
const TOOL_ICONS: [prefix: string, icon: LucideIcon][] = [
["mealie", ChefHat],
["ynab", Wallet],
["obsidian", NotebookPen],
["journal", BookOpen],
["simbakit", PawPrint],
["simba_search", Cat],
["web_search", Globe],
["homebox", Package],
["bookstack", Library],
["gmail", Mail],
["calendar", CalendarDays],
["beli", Utensils],
["get_current_date", Clock],
];
const iconFor = (tool: string): LucideIcon =>
TOOL_ICONS.find(([prefix]) => tool.startsWith(prefix))?.[1] ?? Wrench;
const MAX_VALUE_CHARS = 40;
/** `month: 2026-10 · group_by: category`, with long values clipped. */
export const summariseInput = (input?: ToolInput): string =>
Object.entries(input ?? {})
.filter(([, value]) => value !== "" && value !== null && value !== undefined)
.map(([key, value]) => {
const text = String(value).replace(/\s+/g, " ");
return `${key}: ${text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text}`;
})
.join(" · ");
export const formatDuration = (ms: number): string =>
ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
/**
* Wall-clock span of the block when the client timed it (parallel calls
* overlap, so summing would overcount); otherwise the sum of server
* durations. Restored conversations have neither.
*/
const totalDuration = (rows: ToolMessage[]): number | null => {
const starts = rows.map((r) => r.startedAt);
const ends = rows.map((r) => r.endedAt);
if (starts.every((t) => t != null) && ends.every((t) => t != null)) {
return Math.max(...(ends as number[])) - Math.min(...(starts as number[]));
}
const timed = rows.filter((r) => r.ms != null);
return timed.length ? timed.reduce((sum, r) => sum + (r.ms ?? 0), 0) : null;
};
type ActivityTimelineProps = {
rows: ToolMessage[];
/** True while this block belongs to the in-flight turn. */
active: boolean;
onOpenArtifact: (ref: ArtifactRef) => void;
};
export const ActivityTimeline = React.memo(
({ rows, active, onOpenArtifact }: ActivityTimelineProps) => {
const working = active || rows.some((r) => r.status === "running");
// null = follow the turn (open while working, collapsed when done).
const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
const listId = useId();
// Collapse once the turn finishes, even if the user toggled mid-run.
useEffect(() => {
if (!working) setUserExpanded(null);
}, [working]);
const expanded = userExpanded ?? working;
const failed = rows.filter((r) => r.status === "error").length;
const total = totalDuration(rows);
const summary = [
"Simba worked",
`${rows.length} ${rows.length === 1 ? "step" : "steps"}`,
total != null ? formatDuration(total) : null,
failed ? `${failed} failed` : null,
]
.filter(Boolean)
.join(" · ");
return (
<div className="flex justify-center message-enter">
<div className="w-full max-w-2xl">
<button
type="button"
onClick={() => setUserExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={listId}
className={cn(
"w-full flex items-center justify-between gap-2",
"px-3 py-1.5 rounded-xl",
"bg-leaf-pale border border-leaf-light/50",
"text-xs text-leaf-dark",
"hover:bg-leaf-light/20 transition-colors cursor-pointer",
)}
>
<span className="flex items-center gap-1.5 min-w-0">
{working ? (
<LoaderCircle size={13} className="animate-spin shrink-0" />
) : failed ? (
<X size={13} className="text-red-500 shrink-0" />
) : (
<Check size={13} className="shrink-0" />
)}
<span className={cn("truncate", working && "italic")}>
{working ? "Simba is working…" : summary}
</span>
</span>
<ChevronDown
size={13}
className={cn(
"text-leaf-dark/60 transition-transform duration-200 shrink-0",
expanded && "rotate-180",
)}
/>
</button>
{expanded && (
<ul
id={listId}
className={cn(
"mt-1 rounded-xl overflow-hidden",
"bg-warm-white border border-sand-light",
"divide-y divide-sand-light/60",
)}
>
{rows.map((row) => (
<TimelineRow
key={row.run_id}
row={row}
onOpenArtifact={onOpenArtifact}
/>
))}
</ul>
)}
</div>
</div>
);
},
);
const TimelineRow = ({
row,
onOpenArtifact,
}: {
row: ToolMessage;
onOpenArtifact: (ref: ArtifactRef) => void;
}) => {
const Icon = iconFor(row.tool);
const inputSummary = summariseInput(row.input);
const artifact = row.artifact;
return (
<li className="flex items-center gap-2.5 px-3 py-2">
<span
className="w-6 h-6 shrink-0 rounded-lg bg-leaf-pale text-leaf-dark flex items-center justify-center"
title={row.tool}
>
<Icon size={13} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-charcoal truncate">{row.label}</div>
{inputSummary && (
<div
className="text-[11px] text-warm-gray font-mono truncate"
title={inputSummary}
>
{inputSummary}
</div>
)}
</div>
{artifact && (
<button
type="button"
onClick={() => onOpenArtifact(artifact)}
title={artifact.title}
aria-label={`Open ${artifact.title}`}
className={cn(
"shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
"bg-amber-pale border border-amber-soft/70 text-[11px] font-medium text-amber-dark",
"hover:bg-amber-soft/40 transition-colors cursor-pointer",
)}
>
<PanelRightOpen size={11} />
Open
</button>
)}
<span className="shrink-0 flex items-center gap-1.5 text-[11px] text-warm-gray tabular-nums">
{row.ms != null && row.status !== "running" && (
<span>{formatDuration(row.ms)}</span>
)}
{row.status === "running" ? (
<LoaderCircle
size={14}
className="animate-spin text-leaf"
aria-label="Running"
/>
) : row.status === "error" ? (
<X size={14} className="text-red-500" aria-label="Failed" />
) : (
<Check size={14} className="text-leaf" aria-label="Done" />
)}
</span>
</li>
);
};
+36 -6
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { PanelLeftOpen, Menu, X } from "lucide-react";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
import { ActivityTimeline } from "./ActivityTimeline";
import { ThinkingBubble } from "./ThinkingBubble";
import { MessageInput } from "./MessageInput";
import { SidebarContent } from "./SidebarContent";
@@ -11,7 +11,8 @@ import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
import { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations";
import { useChat } from "../hooks/useChat";
import { useChat, type ToolMessage } from "../hooks/useChat";
import type { ArtifactRef } from "../api/artifactTypes";
import catIcon from "../assets/cat.png";
type ChatScreenProps = {
@@ -73,6 +74,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
scrollToBottom,
});
// TODO(worker D): open the artifact in the side panel.
const handleOpenArtifact = useCallback((_ref: ArtifactRef) => {}, []);
// Keep ref in sync for scrollToBottom behavior
isLoadingRef.current = isLoading;
@@ -248,6 +252,29 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto flex flex-col gap-6">
{messages.map((msg, index) => {
if (msg.speaker === "tool") {
// Consecutive tool calls render as one timeline block,
// emitted at the first row of the run.
if (index > 0 && messages[index - 1].speaker === "tool") {
return null;
}
let end = index;
while (end < messages.length && messages[end].speaker === "tool") {
end++;
}
const rows = messages.slice(index, end) as ToolMessage[];
return (
<ActivityTimeline
key={`tools-${rows[0].run_id}`}
rows={rows}
// The trailing block of an in-flight turn stays
// "working" until the answer starts streaming.
active={isLoading && end === messages.length}
onOpenArtifact={handleOpenArtifact}
/>
);
}
// When the model has been thinking and content is now streaming,
// render ThinkingBubble just before the streaming simba message
// so it appears above the answer rather than below it.
@@ -259,9 +286,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
msg.speaker === "simba";
const bubble =
msg.speaker === "tool" ? (
<ToolBubble key={index} text={msg.text} />
) : msg.speaker === "simba" ? (
msg.speaker === "simba" ? (
<AnswerBubble key={index} text={msg.text} />
) : (
<QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />
@@ -291,7 +316,12 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
/>
)}
{((isLoading && !streaming && !thinking) || messagesLoading) && (
{/* Placeholder dots, unless the activity timeline already shows progress */}
{((isLoading &&
!streaming &&
!thinking &&
messages[messages.length - 1]?.speaker !== "tool") ||
messagesLoading) && (
<AnswerBubble text="" loading={true} />
)}
@@ -1,19 +0,0 @@
import React from "react";
import { cn } from "../lib/utils";
export const ToolBubble = React.memo(
({ text, pulse = false }: { text: string; pulse?: boolean }) => (
<div className="flex justify-center message-enter">
<div
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
"bg-leaf-pale border border-leaf-light/50",
"text-xs text-leaf-dark italic",
pulse && "animate-pulse",
)}
>
{text}
</div>
</div>
),
);
+114 -39
View File
@@ -1,34 +1,42 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useMatch } from "react-router-dom";
import { conversationService } from "../api/conversationService";
import {
conversationService,
type ToolInput,
} from "../api/conversationService";
import type { ArtifactRef } from "../api/artifactTypes";
import type { Conversation } from "./useConversations";
type Message = {
export type ToolStatus = "running" | "ok" | "error";
export type ToolMessage = {
speaker: "tool";
run_id: string;
tool: string;
label: string;
input?: ToolInput;
status: ToolStatus;
ms?: number;
artifact?: ArtifactRef | null;
// Client-side wall-clock bounds, used for the timeline's total duration.
startedAt?: number;
endedAt?: number;
// Synthesised run_id for a server that sends no run_id; such a row pairs
// with the next tool_end for the same tool.
legacy?: boolean;
};
export type TextMessage = {
speaker: "simba" | "user";
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
const TOOL_MESSAGES: Record<string, string> = {
simba_search: "Searching Simba's records...",
web_search: "Searching the web...",
get_current_date: "Checking today's date...",
ynab_budget_summary: "Checking budget summary...",
ynab_account_balances: "Checking account balances...",
ynab_search_transactions: "Looking up transactions...",
ynab_spending_report: "Adding up spending...",
ynab_category_budget: "Checking category budgets...",
ynab_recent_changes: "Checking recent budget changes...",
ynab_sql: "Querying budget data...",
obsidian_search_notes: "Searching notes...",
obsidian_read_note: "Reading note...",
obsidian_create_note: "Saving note...",
obsidian_create_task: "Creating task...",
journal_get_today: "Reading today's journal...",
journal_get_tasks: "Getting tasks...",
journal_add_task: "Adding task...",
journal_complete_task: "Completing task...",
};
export type Message = TextMessage | ToolMessage;
const fallbackLabel = (tool: string) => `Using ${tool}…`;
let legacyRunCounter = 0;
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
@@ -117,11 +125,22 @@ export function useChat({
const fetched = await conversationService.getConversation(conversationId);
if (cancelled) return;
setMessages(
(fetched.messages ?? []).map((m) => ({
text: m.text,
speaker: m.speaker,
image_key: m.image_key,
})),
(fetched.messages ?? []).map((m): Message => {
if (m.speaker === "tool") {
// Saved tool messages carry no input, status or duration.
// Pre-v2 servers send only the "[<tool> result]" header line.
const tool = m.tool ?? m.text.match(/^\[(\S+) result\]/)?.[1] ?? "";
return {
speaker: "tool",
run_id: m.id,
tool,
label: m.label ?? (tool ? fallbackLabel(tool) : "Used a tool"),
status: "ok",
artifact: m.artifact ?? null,
};
}
return { text: m.text, speaker: m.speaker, image_key: m.image_key };
}),
);
scrollToBottom();
} catch (err) {
@@ -189,8 +208,9 @@ export function useChat({
updateMessages((prev) => {
const updated = [...prev];
for (let i = updated.length - 1; i >= 0; i--) {
if (updated[i].speaker === "user") {
updated[i] = { ...updated[i], image_key: imageKey };
const msg = updated[i];
if (msg.speaker === "user") {
updated[i] = { ...msg, image_key: imageKey };
break;
}
}
@@ -214,11 +234,55 @@ export function useChat({
// Any text after a tool call belongs in a fresh bubble.
streamingActiveRef.current = false;
setThinking(false);
const friendly =
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
updateMessages((prev) =>
prev.concat([{ text: friendly, speaker: "tool" }]),
);
const legacy = !event.run_id;
const row: ToolMessage = {
speaker: "tool",
run_id: event.run_id ?? `legacy-${++legacyRunCounter}`,
tool: event.tool,
label: event.label ?? fallbackLabel(event.tool),
input: event.input,
status: "running",
startedAt: Date.now(),
legacy,
};
updateMessages((prev) => prev.concat([row]));
} else if (event.type === "tool_end") {
const now = Date.now();
updateMessages((prev) => {
// Pair by run_id; a legacy end (no run_id) pairs with the
// oldest still-running legacy row for the same tool.
const idx = prev.findIndex((m) =>
m.speaker === "tool" &&
(event.run_id
? m.run_id === event.run_id
: m.legacy && m.status === "running" && m.tool === event.tool),
);
const ended = {
status: event.status ?? "ok",
artifact: event.artifact ?? null,
endedAt: now,
} as const;
if (idx === -1) {
// End without a matching start: show it as a finished row.
return prev.concat([{
speaker: "tool",
run_id: event.run_id ?? `legacy-${++legacyRunCounter}`,
tool: event.tool,
label: event.label ?? fallbackLabel(event.tool),
ms: event.ms,
...ended,
}]);
}
const row = prev[idx] as ToolMessage;
const updated = [...prev];
updated[idx] = {
...row,
...ended,
label: event.label ?? row.label,
ms: event.ms ?? (row.startedAt != null ? now - row.startedAt : undefined),
};
return updated;
});
} else if (event.type === "content") {
setThinking(false);
if (thinkingStartRef.current !== null && thinkingDurationMs === null) {
@@ -228,6 +292,7 @@ export function useChat({
updateMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last.speaker === "tool") return prev;
updated[updated.length - 1] = {
...last,
text: last.text + event.delta,
@@ -247,10 +312,9 @@ export function useChat({
if (streamingActiveRef.current) {
updateMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
text: event.message,
};
const last = updated[updated.length - 1];
if (last.speaker === "tool") return prev;
updated[updated.length - 1] = { ...last, text: event.message };
return updated;
});
streamingActiveRef.current = false;
@@ -289,6 +353,17 @@ export function useChat({
}
} finally {
if (isMountedRef.current) {
// A stream that ends (or is cancelled) mid-call leaves rows that
// will never get a tool_end; stop their spinners.
setMessages((prev) =>
prev.some((m) => m.speaker === "tool" && m.status === "running")
? prev.map((m) =>
m.speaker === "tool" && m.status === "running"
? { ...m, status: "error", endedAt: Date.now() }
: m,
)
: prev,
);
setIsLoading(false);
setStreaming(false);
setThinking(false);
@@ -0,0 +1,424 @@
#!/usr/bin/env node
// Dependency-free mock of the SimbaRAG API for frontend work on the Cowork UI.
// Speaks the contract in docs/cowork-ui-plan.md: serves dist/ with an SPA
// catch-all, stubs auth + conversation CRUD + artifacts, and streams
// contract-shaped SSE from /api/conversation/stream-query.
//
// yarn build && node test-fixtures/cowork/mock-server.mjs [port]
//
// Seed localStorage access_token/refresh_token with any value to get past
// the auth gate. The stream scenario is picked by a keyword in the query:
// "parallel" two interleaved calls, one with an artifact
// "error" a call that fails, then an answer
// "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool}
// "plain" no tools, just a streamed answer
// anything else: two sequential calls, then a streamed answer
// A conversation with id "seeded" exists up front with saved tool messages.
// Every *.json in this directory with `kind` and `data` is loaded as an
// artifact (`id` defaults to the file name), so per-kind fixtures can be
// dropped in alongside this file.
// MOCK_DELAY_SCALE=0.2 speeds the stream up; 0 makes it instant.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(here, "../../dist");
const port = Number(process.argv[2] ?? process.env.PORT ?? 5173);
const delayScale = Number(process.env.MOCK_DELAY_SCALE ?? 1);
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".webmanifest": "application/manifest+json",
};
const now = () => new Date().toISOString();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms * delayScale));
const uuid = () => crypto.randomUUID();
// ---------------------------------------------------------------- state
/** @type {Map<string, any>} full Artifact records by id */
const artifacts = new Map();
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
const conversations = new Map();
function addArtifact(conversationId, tool, a) {
const record = {
id: a.id ?? uuid(),
kind: a.kind,
title: a.title,
data: a.data ?? {},
source_ref: a.source_ref ?? null,
tool: a.tool ?? tool,
conversation_id: conversationId,
message_id: a.message_id ?? null,
created_at: a.created_at ?? now(),
};
artifacts.set(record.id, record);
return record;
}
const refOf = (a) => (a ? { id: a.id, kind: a.kind, title: a.title } : null);
const SPENDING = {
kind: "ynab.spending_report",
title: "October spending by category",
data: {
start_date: "2026-10-01",
end_date: "2026-10-12",
group_by: "category",
rows: [
{ key: "Groceries", amount: -412.18, count: 9 },
{ key: "Dining Out", amount: -186.4, count: 6 },
{ key: "Cat Supplies", amount: -74.99, count: 2 },
],
total: -673.57,
},
source_ref: { system: "ynab" },
};
const RECIPE = {
kind: "mealie.recipe",
title: "Miso Salmon",
data: {
slug: "miso-salmon",
name: "Miso Salmon",
description: "Weeknight salmon glazed with white miso.",
servings: 2,
prep_time: "10 min",
cook_time: "12 min",
total_time: "22 min",
ingredients: ["2 salmon fillets", "2 tbsp white miso", "1 tbsp mirin"],
instructions: [{ text: "Whisk the glaze." }, { text: "Broil 10–12 minutes." }],
tags: ["dinner", "fish"],
},
source_ref: { system: "mealie", id: "miso-salmon" },
};
function newConversation(id = uuid(), name = "New Conversation") {
const c = { id, name, created_at: now(), updated_at: now(), messages: [] };
conversations.set(id, c);
return c;
}
function pushMessage(c, msg) {
const m = { id: uuid(), created_at: now(), image_key: null, ...msg };
c.messages.push(m);
c.updated_at = now();
return m;
}
// Saved tool messages as GET /api/conversation/<id> returns them: the header
// line in `text`, plus tool/label/artifact.
function seed() {
const c = newConversation("seeded", "Budget check (seeded)");
pushMessage(c, { speaker: "user", text: "How much did we spend on food this month?" });
const spend = addArtifact(c.id, "ynab_spending_report", SPENDING);
pushMessage(c, {
speaker: "tool",
text: "[get_current_date result]",
tool: "get_current_date",
label: "Checking today's date…",
artifact: null,
});
pushMessage(c, {
speaker: "tool",
text: "[ynab_spending_report result]",
tool: "ynab_spending_report",
label: "Building spending report…",
artifact: refOf(spend),
});
pushMessage(c, {
speaker: "simba",
text: "You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out.",
});
// A pre-v2 saved tool message: no tool/label/artifact fields at all.
pushMessage(c, { speaker: "user", text: "Anything on the web about miso salmon?" });
pushMessage(c, { speaker: "tool", text: "[web_search result]" });
pushMessage(c, { speaker: "simba", text: "Plenty — broil it for 10–12 minutes." });
}
function loadFixtureArtifacts() {
for (const f of fs.readdirSync(here)) {
if (!f.endsWith(".json")) continue;
try {
const a = JSON.parse(fs.readFileSync(path.join(here, f), "utf8"));
if (a && a.kind && a.data) {
addArtifact(a.conversation_id ?? "seeded", a.tool ?? "fixture", {
id: path.basename(f, ".json"),
...a,
});
}
} catch (err) {
console.warn(`skipping fixture ${f}: ${err.message}`);
}
}
}
seed();
loadFixtureArtifacts();
// ---------------------------------------------------------------- SSE scenarios
const LABELS = {
get_current_date: "Checking today's date…",
ynab_spending_report: "Building spending report…",
mealie_get_recipe: "Opening recipe…",
obsidian_search_notes: "Searching notes…",
web_search: "Searching the web…",
simba_search: "Searching Simba's records…",
};
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
/**
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
* into contract-shaped events; ctx persists tool messages and artifacts.
*/
function scenario(query) {
const q = query.toLowerCase();
const answer = (text) =>
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
if (q.includes("plain")) {
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
}
if (q.includes("legacy")) {
return [
[300, { type: "tool_start", tool: "web_search" }],
[1200, { type: "tool_end", tool: "web_search" }],
[200, { type: "tool_start", tool: "simba_search" }],
[900, { type: "tool_end", tool: "simba_search" }],
...answer("An older server sent those tool events without run ids."),
];
}
if (q.includes("parallel")) {
return [
[300, { start: "r1", tool: "ynab_spending_report", input: { start_date: "2026-10-01", end_date: "2026-10-12", group_by: "category" } }],
[80, { start: "r2", tool: "mealie_get_recipe", input: { slug: "miso-salmon" } }],
[900, { end: "r2", artifact: RECIPE }],
[1100, { end: "r1", artifact: SPENDING }],
...answer("Food spending is **$598.58** this month, and I pulled up the miso salmon recipe for tonight."),
];
}
if (q.includes("error")) {
return [
[300, { start: "e1", tool: "obsidian_search_notes", input: { query: "vet appointment", limit: 5 } }],
[800, { end: "e1", status: "error" }],
[200, { start: "e2", tool: "web_search", input: { query: "cat vet near me open sunday" } }],
[1000, { end: "e2" }],
...answer("I couldn't search your notes, but a couple of vets nearby are open on Sundays."),
];
}
return [
[300, { type: "thinking" }],
[200, { type: "thinking_delta", delta: "Need today's date before the budget lookup." }],
[300, { start: "s1", tool: "get_current_date", input: {} }],
[400, { end: "s1" }],
[200, { start: "s2", tool: "ynab_spending_report", input: { month: "2026-10", group_by: "category", include_hidden_categories_with_a_long_name: true } }],
[1500, { end: "s2" }],
...answer("You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out."),
];
}
async function streamQuery(req, res, body) {
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = body.query.slice(0, 40);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let closed = false;
res.on("close", () => (closed = true));
const send = (event) => !closed && res.write(`data: ${JSON.stringify(event)}\n\n`);
const runs = new Map(); // key -> {run_id, tool, started}
let text = "";
for (const [delay, step] of scenario(body.query)) {
await sleep(delay);
if (closed) return;
if (step.start) {
const run = { run_id: uuid(), tool: step.tool, started: Date.now() };
runs.set(step.start, run);
send({ type: "tool_start", run_id: run.run_id, tool: run.tool, label: label(run.tool), input: step.input ?? {} });
} else if (step.end) {
const run = runs.get(step.end);
const status = step.status ?? "ok";
const msg = pushMessage(c, {
speaker: "tool",
text: `[${run.tool} result]`,
tool: run.tool,
label: label(run.tool),
artifact: null,
});
let ref = null;
if (step.artifact && status === "ok") {
ref = refOf(addArtifact(c.id, run.tool, { ...step.artifact, message_id: msg.id }));
msg.artifact = ref;
}
send({
type: "tool_end",
run_id: run.run_id,
tool: run.tool,
label: label(run.tool),
status,
ms: Date.now() - run.started,
artifact: ref,
});
} else {
if (step.type === "tool_end") {
pushMessage(c, { speaker: "tool", text: `[${step.tool} result]` });
}
if (step.type === "content") text += step.delta;
send(step);
}
}
pushMessage(c, { speaker: "simba", text });
send({ type: "response", message: text });
res.end("data: [DONE]\n\n");
}
// ---------------------------------------------------------------- HTTP
const json = (res, status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
const readBody = (req) =>
new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
try {
resolve(raw ? JSON.parse(raw) : {});
} catch {
resolve({});
}
});
});
const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at });
async function api(req, res, url) {
const p = url.pathname.replace(/\/+$/, "") || "/";
const m = req.method;
if (p === "/api/user/me") {
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
}
if (p === "/api/user/refresh") return json(res, 200, { access_token: "mock-access-token" });
if (p === "/api/user/oidc/login" || p === "/api/user/login") return json(res, 200, {});
if (p === "/api/user/admin/users") return json(res, 200, []);
if (p.startsWith("/api/scheduled")) return json(res, 200, []);
if (p === "/api/conversation/stream-query" && m === "POST") {
return streamQuery(req, res, await readBody(req));
}
if (p === "/api/conversation/query" && m === "POST") {
return json(res, 200, { response: "meow (mock)" });
}
if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") {
return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` });
}
if (p.startsWith("/api/conversation/image/")) {
return json(res, 200, { url: "/favicon.ico" });
}
if (p === "/api/conversation") {
if (m === "POST") return json(res, 200, summary(newConversation()));
const search = (url.searchParams.get("search") ?? "").toLowerCase();
let list = [...conversations.values()]
.filter((c) => c.name.toLowerCase().includes(search))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.map(summary);
const limit = url.searchParams.get("limit");
if (limit != null) {
const offset = Number(url.searchParams.get("offset") ?? 0);
list = list.slice(offset, offset + Number(limit));
}
return json(res, 200, list);
}
const conv = p.match(/^\/api\/conversation\/([^/]+)$/);
if (conv) {
const c = conversations.get(conv[1]);
if (!c) return json(res, 404, { error: "conversation not found" });
if (m === "DELETE") {
conversations.delete(c.id);
return json(res, 200, { success: true });
}
if (m === "PATCH") {
c.name = (await readBody(req)).name ?? c.name;
return json(res, 200, summary(c));
}
return json(res, 200, {
...summary(c),
messages: c.messages.map(({ id, text, speaker, created_at, image_key, tool, label, artifact }) =>
speaker === "tool"
? { id, text, speaker, created_at, image_key, ...(tool ? { tool, label, artifact } : {}) }
: { id, text, speaker, created_at, image_key },
),
});
}
if (p === "/api/artifacts") {
const cid = url.searchParams.get("conversation_id");
return json(
res,
200,
[...artifacts.values()]
.filter((a) => !cid || a.conversation_id === cid)
.map((a) => ({ ...refOf(a), tool: a.tool, message_id: a.message_id, created_at: a.created_at })),
);
}
const art = p.match(/^\/api\/artifacts\/([^/]+)$/);
if (art) {
const a = artifacts.get(art[1]);
return a ? json(res, 200, a) : json(res, 404, { error: "not found" });
}
return json(res, 404, { error: `mock: no route for ${m} ${p}` });
}
function serveStatic(res, pathname) {
const file = path.join(distDir, path.normalize(pathname));
if (file.startsWith(distDir) && fs.existsSync(file) && fs.statSync(file).isFile()) {
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream" });
return fs.createReadStream(file).pipe(res);
}
// SPA catch-all, like app.py.
const index = path.join(distDir, "index.html");
if (!fs.existsSync(index)) {
res.writeHead(500);
return res.end("dist/index.html missing: run `yarn build` first");
}
res.writeHead(200, { "Content-Type": MIME[".html"] });
fs.createReadStream(index).pipe(res);
}
http
.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
try {
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
return serveStatic(res, decodeURIComponent(url.pathname));
} catch (err) {
console.error(err);
if (!res.headersSent) json(res, 500, { error: String(err) });
}
})
.listen(port, () => console.log(`mock SimbaRAG on http://localhost:${port}`));