Cowork D: artifact side panel with per-kind renderers #65

Merged
ryan merged 4 commits from feat/cowork-artifact-panel into main 2026-10-12 18:41:43 -04:00
30 changed files with 2305 additions and 26 deletions
+8
View File
@@ -202,3 +202,11 @@ body {
box-shadow: 0 1px 3px rgba(0,0,0,0.15); box-shadow: 0 1px 3px rgba(0,0,0,0.15);
} }
.toggle-track.checked .toggle-thumb { transform: translateX(16px); } .toggle-track.checked .toggle-thumb { transform: translateX(16px); }
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-sheet-in {
animation: sheet-in 0.25s ease-out;
}
+36
View File
@@ -0,0 +1,36 @@
import { userService } from "./userService";
import type { Artifact, ArtifactRef } from "./artifactTypes";
/** A list entry from `GET /api/artifacts`: the ref plus where it came from. */
export type ArtifactListItem = ArtifactRef & {
tool: string;
message_id: string | null;
created_at: string;
};
class ArtifactService {
private baseUrl = "/api/artifacts";
async listArtifacts(conversationId: string): Promise<ArtifactListItem[]> {
const qs = new URLSearchParams({ conversation_id: conversationId });
const response = await userService.fetchWithAuth(`${this.baseUrl}?${qs}`);
if (!response.ok) {
throw new Error("Failed to list artifacts");
}
return await response.json();
}
async getArtifact(id: string): Promise<Artifact> {
const response = await userService.fetchWithAuth(
`${this.baseUrl}/${encodeURIComponent(id)}`,
);
if (!response.ok) {
throw new Error(
response.status === 404 ? "Artifact not found" : "Failed to load artifact",
);
}
return await response.json();
}
}
export const artifactService = new ArtifactService();
+77 -13
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useState, useRef } from "react"; import React, { useCallback, useState, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useMatch, useNavigate } from "react-router-dom";
import { PanelLeftOpen, Menu, X } from "lucide-react"; import { Library, PanelLeftOpen, Menu, X } from "lucide-react";
import { QuestionBubble } from "./QuestionBubble"; import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble"; import { AnswerBubble } from "./AnswerBubble";
import { ActivityTimeline } from "./ActivityTimeline"; import { ActivityTimeline } from "./ActivityTimeline";
@@ -9,6 +9,8 @@ import { MessageInput } from "./MessageInput";
import { SidebarContent } from "./SidebarContent"; import { SidebarContent } from "./SidebarContent";
import { AdminPanel } from "./AdminPanel"; import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel"; import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
import { ArtifactPanel } from "./artifacts/ArtifactPanel";
import { AUTO_OPEN_MIN_WIDTH, useArtifactPanel } from "./artifacts/useArtifactPanel";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations"; import { useConversations } from "../hooks/useConversations";
import { useChat, type ToolMessage } from "../hooks/useChat"; import { useChat, type ToolMessage } from "../hooks/useChat";
@@ -51,6 +53,31 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]); const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
const conversationIdRef = useRef(conversationId);
conversationIdRef.current = conversationId;
const artifactPanel = useArtifactPanel(conversationId);
// Opens an artifact in the panel; the timeline's "Open" chip calls this.
const openArtifact = artifactPanel.open;
// Auto-open on wide windows; newest wins. Narrower windows rely on the
// timeline chip or the header's Artifacts button.
const handleArtifact = useCallback(
(ref: ArtifactRef, turnConversationId: string) => {
if (turnConversationId !== conversationIdRef.current) return;
if (window.innerWidth < AUTO_OPEN_MIN_WIDTH) return;
openArtifact(ref, { auto: true });
},
[openArtifact],
);
const toggleArtifacts = useCallback(() => {
const p = artifactPanel;
if (p.isOpen && (p.view === "library" || !p.active)) p.collapse();
else p.showLibrary();
}, [artifactPanel]);
const { const {
messages, messages,
isLoading, isLoading,
@@ -72,11 +99,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
refreshConversations, refreshConversations,
onSessionExpired, onSessionExpired,
scrollToBottom, scrollToBottom,
onArtifact: handleArtifact,
}); });
// TODO(worker D): open the artifact in the side panel.
const handleOpenArtifact = useCallback((_ref: ArtifactRef) => {}, []);
// Keep ref in sync for scrollToBottom behavior // Keep ref in sync for scrollToBottom behavior
isLoadingRef.current = isLoading; isLoadingRef.current = isLoading;
@@ -209,15 +234,52 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
asksimba asksimba
</h1> </h1>
</div> </div>
<button <div className="flex items-center gap-1">
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer" {conversationId && (
onClick={() => setShowConversations(true)} <button
aria-label="Open menu" className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
> onClick={toggleArtifacts}
<Menu size={16} /> aria-label="Artifacts"
</button> >
<Library size={16} />
</button>
)}
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations(true)}
aria-label="Open menu"
>
<Menu size={16} />
</button>
</div>
</header> </header>
{conversationId && (
<header className="hidden md:flex items-center justify-between gap-3 px-4 py-2 border-b border-sand-light/40">
<h2 className="min-w-0 truncate text-sm font-semibold text-warm-gray">
{selectedConversation?.title}
</h2>
<button
onClick={toggleArtifacts}
aria-pressed={artifactPanel.isOpen}
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold transition-all cursor-pointer",
artifactPanel.isOpen
? "bg-leaf-pale text-leaf-dark"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<Library size={14} />
Artifacts
{artifactPanel.tabs.length > 0 && (
<span className="rounded-full bg-leaf/15 px-1.5 text-[10px] text-leaf-dark">
{artifactPanel.tabs.length}
</span>
)}
</button>
</header>
)}
{messages.length === 0 && !messagesLoading ? ( {messages.length === 0 && !messagesLoading ? (
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6"> <div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
<div className="relative"> <div className="relative">
@@ -270,7 +332,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
// The trailing block of an in-flight turn stays // The trailing block of an in-flight turn stays
// "working" until the answer starts streaming. // "working" until the answer starts streaming.
active={isLoading && end === messages.length} active={isLoading && end === messages.length}
onOpenArtifact={handleOpenArtifact} onOpenArtifact={openArtifact}
/> />
); );
} }
@@ -378,6 +440,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
</> </>
)} )}
</div> </div>
<ArtifactPanel panel={artifactPanel} conversationId={conversationId} />
</div> </div>
); );
}; };
@@ -0,0 +1,384 @@
import { type ReactNode, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
Library,
LoaderCircle,
PanelRightClose,
PanelRightOpen,
RotateCw,
TriangleAlert,
X,
} from "lucide-react";
import { artifactService, type ArtifactListItem } from "../../api/artifactService";
import type { ArtifactRef } from "../../api/artifactTypes";
import { cn } from "../../lib/utils";
import { ArtifactRenderer, kindIcon } from "./renderers";
import { formatDate } from "./renderers/format";
import type { ArtifactPanelState } from "./useArtifactPanel";
const DESKTOP_QUERY = "(min-width: 1024px)"; // Tailwind `lg`
function useMediaQuery(query: string): boolean {
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches);
}
const iconButton =
"w-8 h-8 shrink-0 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer";
type ArtifactPanelProps = {
panel: ArtifactPanelState;
conversationId: string | undefined;
};
/**
* Right-hand artifact pane (lg and up) or bottom sheet (below lg). Renders
* nothing until something opens it.
*/
export const ArtifactPanel = ({ panel, conversationId }: ArtifactPanelProps) => {
const isDesktop = useMediaQuery(DESKTOP_QUERY);
const { isOpen, tabs } = panel;
// Escape closes the mobile sheet.
useEffect(() => {
if (isDesktop || !isOpen) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && panel.collapse();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isDesktop, isOpen, panel.collapse]);
if (isDesktop) {
if (!isOpen) {
if (tabs.length === 0) return null;
return <CollapsedRail panel={panel} />;
}
return (
<aside
className="w-[440px] shrink-0 flex flex-col h-full border-l border-sand-light/70 bg-warm-white"
aria-label="Artifacts"
>
<PanelHeader panel={panel} closeIcon={<PanelRightClose size={16} />} closeLabel="Collapse panel" />
<PanelBody panel={panel} conversationId={conversationId} />
</aside>
);
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-40 flex flex-col justify-end" role="dialog" aria-label="Artifacts">
<div className="absolute inset-0 bg-charcoal/40 backdrop-blur-sm" onClick={panel.collapse} />
<div className="relative flex h-[85dvh] flex-col overflow-hidden rounded-t-2xl bg-warm-white shadow-2xl animate-sheet-in">
<div className="flex justify-center pt-2">
<span className="h-1 w-10 rounded-full bg-sand" />
</div>
<PanelHeader panel={panel} closeIcon={<X size={16} />} closeLabel="Close artifacts" />
<PanelBody panel={panel} conversationId={conversationId} />
</div>
</div>
);
};
const CollapsedRail = ({ panel }: { panel: ArtifactPanelState }) => (
<aside className="w-12 shrink-0 flex flex-col items-center gap-2 py-3 h-full border-l border-sand-light/70 bg-warm-white">
<button onClick={panel.expand} className={iconButton} aria-label="Expand artifacts panel">
<PanelRightOpen size={16} />
</button>
<div className="h-px w-6 bg-sand-light" />
{panel.tabs.map((tab) => {
const Icon = kindIcon(tab.kind);
return (
<button
key={tab.id}
onClick={() => panel.open(tab)}
title={tab.title}
aria-label={`Show ${tab.title}`}
className={cn(iconButton, panel.active?.id === tab.id && "text-leaf-dark bg-leaf-pale")}
>
<Icon size={15} />
</button>
);
})}
</aside>
);
const PanelHeader = ({
panel,
closeIcon,
closeLabel,
}: {
panel: ArtifactPanelState;
closeIcon: ReactNode;
closeLabel: string;
}) => {
const libraryActive = panel.view === "library" || !panel.active;
const stripRef = useRef<HTMLDivElement>(null);
// Keep the active tab visible when the strip overflows.
const activeId = panel.active?.id;
useEffect(() => {
// The wrapper, so the tab's close button is in view too.
stripRef.current
?.querySelector('[aria-selected="true"]')
?.parentElement?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeId, libraryActive, panel.tabs.length]);
return (
<div className="flex items-center gap-1 border-b border-sand-light/70 px-2 py-2">
<div
ref={stripRef}
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none]"
role="tablist"
>
{panel.tabs.length === 0 && (
<span className="px-2 text-sm font-bold text-charcoal" style={{ fontFamily: "var(--font-display)" }}>
Artifacts
</span>
)}
{panel.tabs.map((tab) => (
<Tab
key={tab.id}
tab={tab}
selected={!libraryActive && panel.active?.id === tab.id}
onSelect={() => panel.open(tab)}
onClose={() => panel.close(tab.id)}
/>
))}
</div>
<button
onClick={() => (libraryActive && panel.active ? panel.setView("tab") : panel.showLibrary())}
className={cn(iconButton, libraryActive && "text-leaf-dark bg-leaf-pale")}
aria-label="All artifacts in this conversation"
aria-pressed={libraryActive}
title="All artifacts"
>
<Library size={16} />
</button>
<button onClick={panel.collapse} className={iconButton} aria-label={closeLabel} title={closeLabel}>
{closeIcon}
</button>
</div>
);
};
const Tab = ({
tab,
selected,
onSelect,
onClose,
}: {
tab: ArtifactRef;
selected: boolean;
onSelect: () => void;
onClose: () => void;
}) => {
const Icon = kindIcon(tab.kind);
return (
<div
className={cn(
"group flex min-w-[96px] max-w-[200px] shrink items-center rounded-lg border text-xs transition-colors",
selected
? "border-leaf-light bg-leaf-pale text-leaf-dark"
: "border-transparent text-warm-gray hover:bg-cream-dark hover:text-charcoal",
)}
>
<button
role="tab"
aria-selected={selected}
onClick={onSelect}
title={tab.title}
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 pl-2 pr-1 font-semibold cursor-pointer"
>
<Icon size={13} className="shrink-0" />
<span className="truncate">{tab.title}</span>
</button>
<button
onClick={onClose}
aria-label={`Close ${tab.title}`}
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md opacity-60 hover:bg-sand-light hover:opacity-100 cursor-pointer"
>
<X size={11} />
</button>
</div>
);
};
const PanelBody = ({
panel,
conversationId,
}: {
panel: ArtifactPanelState;
conversationId: string | undefined;
}) => {
const { active, activeEntry } = panel;
if (panel.view === "library" || !active || !activeEntry) {
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
<ArtifactLibrary conversationId={conversationId} panel={panel} />
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
{activeEntry.status === "loading" && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-warm-gray">
<LoaderCircle size={22} className="animate-spin text-leaf" />
<span className="text-sm">Loading {active.title}…</span>
</div>
)}
{activeEntry.status === "error" && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<TriangleAlert size={22} className="text-amber-dark" />
<div>
<p className="text-sm font-semibold text-charcoal">Couldn&apos;t load this artifact</p>
<p className="text-xs text-warm-gray">{activeEntry.error}</p>
</div>
<button
onClick={() => panel.retry(active.id)}
className="inline-flex items-center gap-1.5 rounded-xl border border-sand px-3 py-1.5 text-xs font-semibold text-warm-gray hover:bg-cream-dark hover:text-charcoal cursor-pointer"
>
<RotateCw size={12} /> Retry
</button>
</div>
)}
{activeEntry.status === "ready" && (
<article key={active.id} className="message-enter">
<h2
className="mb-3 text-lg font-bold leading-snug text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
{activeEntry.artifact.title}
</h2>
<ArtifactRenderer artifact={activeEntry.artifact} />
</article>
)}
</div>
);
};
type LibraryState =
| { status: "loading" }
| { status: "error" }
| { status: "ready"; items: ArtifactListItem[] };
/** Every artifact in the conversation, newest first, so earlier ones can be reopened. */
const ArtifactLibrary = ({
conversationId,
panel,
}: {
conversationId: string | undefined;
panel: ArtifactPanelState;
}) => {
const [state, setState] = useState<LibraryState>({ status: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!conversationId) {
setState({ status: "ready", items: [] });
return;
}
let cancelled = false;
setState({ status: "loading" });
artifactService
.listArtifacts(conversationId)
.then((items) => {
if (cancelled) return;
const sorted = [...items].sort((a, b) => b.created_at.localeCompare(a.created_at));
setState({ status: "ready", items: sorted });
})
.catch((err) => {
console.error("Failed to list artifacts:", err);
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [conversationId, attempt]);
if (state.status === "loading") {
return (
<ul className="flex flex-col gap-2" aria-busy="true">
{[0, 1, 2].map((i) => (
<li key={i} className="h-14 rounded-xl bg-sand-light/50 animate-pulse" />
))}
</ul>
);
}
if (state.status === "error") {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<TriangleAlert size={22} className="text-amber-dark" />
<p className="text-sm text-charcoal">Couldn&apos;t load this conversation&apos;s artifacts.</p>
<button
onClick={() => setAttempt((n) => n + 1)}
className="inline-flex items-center gap-1.5 rounded-xl border border-sand px-3 py-1.5 text-xs font-semibold text-warm-gray hover:bg-cream-dark hover:text-charcoal cursor-pointer"
>
<RotateCw size={12} /> Retry
</button>
</div>
);
}
if (state.items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-amber-pale text-amber-dark">
<Library size={20} />
</div>
<p className="text-sm font-semibold text-charcoal">No artifacts yet</p>
<p className="max-w-[260px] text-xs leading-relaxed text-warm-gray">
When Simba pulls up a recipe, a budget, a note or some documents, it shows up here.
</p>
</div>
);
}
const openIds = new Set(panel.tabs.map((t) => t.id));
return (
<div className="flex flex-col gap-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-warm-gray">
In this conversation · {state.items.length}
</p>
<ul className="flex flex-col gap-1.5">
{state.items.map((item) => {
const Icon = kindIcon(item.kind);
return (
<li key={item.id}>
<button
onClick={() => panel.open({ id: item.id, kind: item.kind, title: item.title })}
className="flex w-full items-center gap-3 rounded-xl border border-sand-light bg-warm-white px-3 py-2.5 text-left hover:border-leaf-light hover:bg-leaf-pale/50 transition-colors cursor-pointer"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-cream-dark text-leaf-dark">
<Icon size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-charcoal">{item.title}</span>
<span className="block truncate text-[11px] text-warm-gray">
{formatDate(item.created_at, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
{" · "}
{item.tool}
</span>
</span>
{openIds.has(item.id) && (
<span className="shrink-0 text-[10px] font-bold uppercase tracking-wide text-leaf-dark">Open</span>
)}
</button>
</li>
);
})}
</ul>
</div>
);
};
@@ -0,0 +1,63 @@
import { formatDate, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { MealieMealPlanData, RendererProps } from "./types";
const todayIso = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
export const MealieMealPlan = ({ data }: RendererProps<MealieMealPlanData>) => {
const days = [...(data.days ?? [])].sort((a, b) => a.date.localeCompare(b.date));
const today = todayIso();
return (
<div className="flex flex-col gap-3">
<Subtle>{formatRange(data.start_date, data.end_date)}</Subtle>
{days.length === 0 ? (
<EmptyNote>Nothing planned.</EmptyNote>
) : (
<ol className="flex flex-col gap-2">
{days.map((day) => (
<li
key={day.date}
className={
day.date === today
? "rounded-xl border border-leaf-light bg-leaf-pale/60 px-3 py-2.5"
: "rounded-xl border border-sand-light bg-warm-white px-3 py-2.5"
}
>
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-sm font-bold text-charcoal">
{formatDate(day.date, { weekday: "long" })}
</span>
<span className="text-xs text-warm-gray">
{formatDate(day.date, { month: "short", day: "numeric" })}
</span>
{day.date === today && (
<span className="text-[10px] font-bold uppercase tracking-wide text-leaf-dark">Today</span>
)}
</div>
{day.meals.length === 0 ? (
<p className="text-xs italic text-warm-gray">Nothing planned</p>
) : (
<ul className="flex flex-col gap-1">
{day.meals.map((meal, i) => (
<li key={i} className="flex items-baseline gap-2 text-sm">
<span className="w-[4.5rem] shrink-0 text-[11px] font-semibold uppercase tracking-wide text-amber-dark">
{meal.entry_type}
</span>
<span className="min-w-0">
<span className="text-charcoal">{meal.name}</span>
{meal.note && <span className="block text-xs text-warm-gray">{meal.note}</span>}
</span>
</li>
))}
</ul>
)}
</li>
))}
</ol>
)}
</div>
);
};
@@ -0,0 +1,116 @@
import { useState } from "react";
import { Clock, Users } from "lucide-react";
import { cn } from "../../../lib/utils";
import { Chip, OpenExternal, SectionTitle } from "./shared";
import type { MealieRecipeData, RendererProps } from "./types";
export const MealieRecipe = ({ data, sourceRef }: RendererProps<MealieRecipeData>) => {
const [ticked, setTicked] = useState<Set<number>>(new Set());
const [imageFailed, setImageFailed] = useState(false);
const ingredients = data.ingredients ?? [];
const instructions = data.instructions ?? [];
const toggle = (i: number) =>
setTicked((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
const times = [
["Prep", data.prep_time],
["Cook", data.cook_time],
["Total", data.total_time],
].filter(([, v]) => v) as [string, string][];
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-3">
{data.image_url && !imageFailed && (
<img
src={data.image_url}
alt={data.name}
onError={() => setImageFailed(true)}
className="aspect-[16/9] w-full rounded-xl object-cover border border-sand-light"
/>
)}
{/* The panel already titles the artifact with the recipe name. */}
{data.description && <p className="text-sm leading-relaxed text-warm-gray">{data.description}</p>}
<div className="flex flex-wrap gap-1.5">
{times.map(([label, value]) => (
<Chip key={label} icon={<Clock size={12} />}>
{label} {value}
</Chip>
))}
{data.servings != null && data.servings !== "" && (
<Chip icon={<Users size={12} />}>Serves {data.servings}</Chip>
)}
</div>
{(data.tags?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.tags.map((t) => (
<Chip key={t} tone="leaf">
{t}
</Chip>
))}
</div>
)}
{sourceRef?.url && (
<div>
<OpenExternal href={sourceRef.url} label="Open in Mealie" />
</div>
)}
</header>
{ingredients.length > 0 && (
<section>
<SectionTitle>
Ingredients
{ticked.size > 0 && (
<span className="ml-1.5 normal-case tracking-normal font-medium">
· {ticked.size}/{ingredients.length}
</span>
)}
</SectionTitle>
<ul className="flex flex-col gap-0.5">
{ingredients.map((ing, i) => (
<li key={i}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2 py-1.5 text-sm hover:bg-cream-dark/60">
<input
type="checkbox"
checked={ticked.has(i)}
onChange={() => toggle(i)}
className="mt-0.5 h-4 w-4 shrink-0 accent-leaf cursor-pointer"
/>
<span className={cn(ticked.has(i) ? "text-warm-gray line-through" : "text-charcoal")}>
{ing}
</span>
</label>
</li>
))}
</ul>
</section>
)}
{instructions.length > 0 && (
<section>
<SectionTitle>Steps</SectionTitle>
<ol className="flex flex-col gap-3">
{instructions.map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-amber-pale text-xs font-bold text-amber-dark">
{i + 1}
</span>
<div className="pt-0.5 text-sm leading-relaxed text-charcoal">
{step.title && <div className="font-semibold">{step.title}</div>}
{step.text}
</div>
</li>
))}
</ol>
</section>
)}
</div>
);
};
@@ -0,0 +1,51 @@
import { Check } from "lucide-react";
import { cn } from "../../../lib/utils";
import { EmptyNote } from "./shared";
import type { MealieShoppingListsData, RendererProps } from "./types";
export const MealieShoppingLists = ({ data }: RendererProps<MealieShoppingListsData>) => {
const lists = data.lists ?? [];
if (lists.length === 0) return <EmptyNote>No shopping lists.</EmptyNote>;
return (
<div className="flex flex-col gap-4">
{lists.map((list) => {
const done = list.items.filter((i) => i.checked).length;
return (
<section key={list.name} className="rounded-xl border border-sand-light bg-warm-white">
<header className="flex items-baseline justify-between border-b border-sand-light px-3 py-2">
<h3 className="text-sm font-bold text-charcoal">{list.name}</h3>
<span className="text-xs text-warm-gray">
{done}/{list.items.length} done
</span>
</header>
{list.items.length === 0 ? (
<p className="px-3 py-3 text-xs italic text-warm-gray">Empty list</p>
) : (
<ul className="divide-y divide-sand-light/60">
{list.items.map((item, i) => (
<li key={i} className="flex items-center gap-2.5 px-3 py-1.5 text-sm">
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border",
item.checked ? "border-leaf bg-leaf text-white" : "border-sand",
)}
>
{item.checked && <Check size={11} strokeWidth={3} />}
</span>
<span className={cn("flex-1", item.checked ? "text-warm-gray line-through" : "text-charcoal")}>
{item.name}
</span>
{/* Like the tool's text, quantities of 0/1 aren't worth showing. */}
{item.quantity != null && item.quantity !== "" && Number(item.quantity) !== 0 && Number(item.quantity) !== 1 && (
<span className="text-xs tabular-nums text-warm-gray">{item.quantity}</span>
)}
</li>
))}
</ul>
)}
</section>
);
})}
</div>
);
};
@@ -0,0 +1,30 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Chip, OpenExternal, Subtle } from "./shared";
import type { ObsidianNoteData, RendererProps } from "./types";
export const ObsidianNote = ({ data, sourceRef }: RendererProps<ObsidianNoteData>) => (
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<Subtle className="font-mono break-all">{data.path}</Subtle>
{(data.tags?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.tags.map((t) => (
<Chip key={t} tone="leaf">
#{t}
</Chip>
))}
</div>
)}
{sourceRef?.url && (
<div>
<OpenExternal href={sourceRef.url} label="Open in Obsidian" />
</div>
)}
</header>
{/* Tailwind's preflight strips list markers; notes need them back. */}
<div className="markdown-content text-[15px] leading-relaxed text-charcoal [&_ul]:list-disc [&_ol]:list-decimal [&_ul.contains-task-list]:list-none [&_ul.contains-task-list]:pl-1">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.markdown ?? ""}</ReactMarkdown>
</div>
</div>
);
@@ -0,0 +1,48 @@
import { FileText } from "lucide-react";
import { formatDate } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, SearchDocumentsData } from "./types";
export const SearchDocuments = ({ data }: RendererProps<SearchDocumentsData>) => {
const results = data.results ?? [];
return (
<div className="flex flex-col gap-3">
<Subtle>
“{data.query}” in {data.source} · {results.length} result{results.length === 1 ? "" : "s"}
</Subtle>
{results.length === 0 ? (
<EmptyNote>No matching documents.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{results.map((r, i) => {
const location = r.filename ?? r.path;
return (
<li key={i} className="rounded-xl border border-sand-light bg-warm-white px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-bold leading-snug text-charcoal">{r.title}</h4>
{r.document_type && (
<span className="shrink-0 rounded-full bg-amber-pale px-2 py-0.5 text-[10px] font-semibold text-amber-dark">
{r.document_type}
</span>
)}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-charcoal/80 line-clamp-4">{r.snippet}</p>
{(location || r.date) && (
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-warm-gray">
{location && (
<span className="flex min-w-0 items-center gap-1">
<FileText size={11} className="shrink-0" />
<span className="truncate font-mono">{location}</span>
</span>
)}
{r.date && <span className="ml-auto shrink-0">{formatDate(r.date)}</span>}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
};
@@ -0,0 +1,12 @@
import { Subtle } from "./shared";
export const UnknownArtifact = ({ kind, data }: { kind: string; data: unknown }) => (
<div className="flex flex-col gap-2">
<Subtle>
No viewer for <code className="font-mono">{kind}</code> yet; showing the raw data.
</Subtle>
<pre className="overflow-x-auto rounded-xl bg-charcoal p-3 text-xs leading-relaxed text-cream-dark">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
@@ -0,0 +1,83 @@
import { cn } from "../../../lib/utils";
import { formatMoney, formatMonth, moneyTone } from "./format";
import { EmptyNote } from "./shared";
import type { RendererProps, YnabCategoryBudgetData } from "./types";
type Category = YnabCategoryBudgetData["groups"][number]["categories"][number];
/** Available as a share of assigned; full when nothing was assigned but money is available. */
function availableShare(c: Category): number {
if (c.available <= 0) return 0;
if (c.assigned <= 0) return 1;
return Math.min(1, c.available / c.assigned);
}
const sum = (cats: Category[], key: "assigned" | "activity" | "available") =>
cats.reduce((acc, c) => acc + (c[key] ?? 0), 0);
export const YnabCategoryBudget = ({ data }: RendererProps<YnabCategoryBudgetData>) => {
const groups = (data.groups ?? []).filter((g) => g.categories?.length);
if (groups.length === 0) return <EmptyNote>No categories for this month.</EmptyNote>;
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-warm-gray">{formatMonth(data.month)}</p>
<div className="overflow-x-auto rounded-xl border border-sand-light bg-warm-white">
<table className="w-full text-xs tabular-nums">
<thead>
<tr className="border-b border-sand-light text-[11px] uppercase tracking-wide text-warm-gray">
<th className="px-3 py-2 text-left font-semibold">Category</th>
<th className="px-2 py-2 text-right font-semibold">Assigned</th>
<th className="px-2 py-2 text-right font-semibold">Activity</th>
<th className="px-3 py-2 text-right font-semibold">Available</th>
</tr>
</thead>
{groups.map((group) => (
<tbody key={group.name}>
<tr className="bg-sand-light/50 font-bold text-charcoal">
<td className="px-3 py-1.5">{group.name}</td>
<td className="px-2 py-1.5 text-right">{formatMoney(sum(group.categories, "assigned"))}</td>
<td className="px-2 py-1.5 text-right">{formatMoney(sum(group.categories, "activity"), { signed: true })}</td>
<td className={cn("px-3 py-1.5 text-right", moneyTone(sum(group.categories, "available")))}>
{formatMoney(sum(group.categories, "available"))}
</td>
</tr>
{group.categories.map((c) => {
const overspent = c.available < 0;
return (
<tr
key={c.name}
className={cn("border-t border-sand-light/60", overspent && "bg-red-50")}
>
<td className="px-3 py-1.5 align-top">
<div className={cn("truncate max-w-[150px]", overspent ? "text-red-700 font-semibold" : "text-charcoal")}>
{c.name}
</div>
<div className="mt-1 h-1.5 w-full max-w-[150px] rounded-full bg-sand-light overflow-hidden">
<div
className={cn(
"h-full rounded-full",
overspent ? "bg-red-400 w-full" : "bg-leaf",
)}
style={overspent ? undefined : { width: `${availableShare(c) * 100}%` }}
/>
</div>
</td>
<td className="px-2 py-1.5 text-right align-top text-charcoal">{formatMoney(c.assigned)}</td>
<td className={cn("px-2 py-1.5 text-right align-top", c.activity < 0 ? "text-warm-gray" : "text-leaf-dark")}>
{formatMoney(c.activity, { signed: true })}
</td>
<td className={cn("px-3 py-1.5 text-right align-top font-semibold", overspent ? "text-red-600" : "text-leaf-dark")}>
{formatMoney(c.available)}
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
<p className="text-[11px] text-warm-gray">Bar: available as a share of assigned. Red rows are overspent.</p>
</div>
);
};
@@ -0,0 +1,104 @@
import { formatDate, formatMoney, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, YnabSpendingReportData } from "./types";
type Row = YnabSpendingReportData["rows"][number];
/** "2026-08" → "Aug" (or "Aug 26" when the rows span years); anything else as-is. */
const monthLabel = (key: string, withYear: boolean) =>
/^\d{4}-\d{2}/.test(key)
? formatDate(key, withYear ? { month: "short", year: "2-digit" } : { month: "short" })
: key;
const MonthBars = ({ rows }: { rows: Row[] }) => {
const ordered = [...rows].sort((a, b) => a.key.localeCompare(b.key));
const withYear = new Set(ordered.map((r) => r.key.slice(0, 4))).size > 1;
const label = (key: string) => monthLabel(key, withYear);
const max = Math.max(...ordered.map((r) => r.amount), 1);
return (
<div className="rounded-xl border border-sand-light bg-warm-white px-3 pt-4 pb-2">
<div className="flex h-44 items-end gap-1.5">
{ordered.map((r) => (
<div
key={r.key}
className="group flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-1"
title={`${monthLabel(r.key, true)}: ${formatMoney(r.amount)}`}
>
<span className="text-[10px] font-semibold tabular-nums text-warm-gray opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{formatMoney(r.amount).replace(/\.\d\d$/, "")}
</span>
<div
className="w-full max-w-10 rounded-t-md bg-amber-glow/80 group-hover:bg-amber-dark transition-colors"
style={{ height: `${Math.max(2, (Math.max(0, r.amount) / max) * 100)}%` }}
/>
</div>
))}
</div>
<div className="mt-1.5 flex gap-1.5 border-t border-sand-light pt-1.5">
{ordered.map((r) => (
<span key={r.key} className="min-w-0 flex-1 truncate text-center text-[10px] text-warm-gray">
{label(r.key)}
</span>
))}
</div>
</div>
);
};
const HorizontalBars = ({ rows }: { rows: Row[] }) => {
// Amounts are positive spending; a net refund (negative) sorts last with no bar.
const ordered = [...rows].sort((a, b) => b.amount - a.amount);
const max = Math.max(...ordered.map((r) => r.amount), 1);
return (
<ul className="flex flex-col gap-2.5">
{ordered.map((r) => (
<li key={r.key}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="truncate text-charcoal">{r.key}</span>
<span className={`shrink-0 font-semibold tabular-nums ${r.amount < 0 ? "text-leaf-dark" : "text-charcoal"}`}>
{formatMoney(r.amount)}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<div className="h-2 flex-1 rounded-full bg-sand-light/70 overflow-hidden">
<div
className="h-full rounded-full bg-leaf"
style={{ width: `${(Math.max(0, r.amount) / max) * 100}%` }}
/>
</div>
{r.count != null && (
<span className="w-12 shrink-0 text-right text-[11px] text-warm-gray">
{r.count} txn{r.count === 1 ? "" : "s"}
</span>
)}
</div>
</li>
))}
</ul>
);
};
export const YnabSpendingReport = ({ data }: RendererProps<YnabSpendingReportData>) => {
const rows = data.rows ?? [];
return (
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<div>
<Subtle>{formatRange(data.start_date, data.end_date)}</Subtle>
<Subtle>Grouped by {data.group_by?.replace(/_/g, " ")}</Subtle>
</div>
<div className="text-right">
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">Total</div>
<div className="text-xl font-bold tabular-nums text-charcoal">{formatMoney(data.total)}</div>
</div>
</div>
{rows.length === 0 ? (
<EmptyNote>No spending in this period.</EmptyNote>
) : data.group_by === "month" ? (
<MonthBars rows={rows} />
) : (
<HorizontalBars rows={rows} />
)}
</div>
);
};
@@ -0,0 +1,48 @@
import { formatMoney, formatMonth } from "./format";
import { EmptyNote, SectionTitle, StatTile } from "./shared";
import type { RendererProps, YnabSummaryData } from "./types";
export const YnabSummary = ({ data }: RendererProps<YnabSummaryData>) => {
const overspent = data.overspent ?? [];
return (
<div className="flex flex-col gap-5">
<p className="text-sm text-warm-gray">{formatMonth(data.month)}</p>
<div className="grid grid-cols-2 gap-2">
<StatTile
label="Ready to assign"
value={formatMoney(data.ready_to_assign)}
tone={data.ready_to_assign < 0 ? "bad" : "good"}
/>
<StatTile label="Income" value={formatMoney(data.income)} />
<StatTile label="Assigned" value={formatMoney(data.assigned)} />
{/* Positive for spending, like the spending report. */}
<StatTile label="Spent" value={formatMoney(data.spent)} />
<StatTile
label="Net worth"
value={formatMoney(data.net_worth)}
tone={data.net_worth < 0 ? "bad" : "default"}
/>
{data.age_of_money != null && (
<StatTile label="Age of money" value={`${data.age_of_money} days`} />
)}
</div>
<section>
<SectionTitle>Overspent ({overspent.length})</SectionTitle>
{overspent.length === 0 ? (
<EmptyNote>Nothing overspent this month.</EmptyNote>
) : (
<ul className="divide-y divide-red-100 rounded-xl border border-red-200 bg-red-50/60">
{overspent.map((c) => (
<li key={c.name} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="text-charcoal">{c.name}</span>
<span className="font-semibold tabular-nums text-red-600">
{formatMoney(c.available)}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
};
@@ -0,0 +1,118 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { cn } from "../../../lib/utils";
import { formatDate, formatMoney, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, YnabTransactionsData } from "./types";
type SortKey = "date" | "payee" | "category" | "amount";
type Sort = { key: SortKey; dir: "asc" | "desc" };
const COLUMNS: { key: SortKey; label: string; align: "left" | "right" }[] = [
{ key: "date", label: "Date", align: "left" },
{ key: "payee", label: "Payee", align: "left" },
{ key: "category", label: "Category", align: "left" },
{ key: "amount", label: "Amount", align: "right" },
];
export const YnabTransactions = ({ data }: RendererProps<YnabTransactionsData>) => {
const [sort, setSort] = useState<Sort>({ key: "date", dir: "desc" });
const transactions = data.transactions ?? [];
const sorted = useMemo(() => {
const factor = sort.dir === "asc" ? 1 : -1;
return [...transactions].sort((a, b) => {
const cmp =
sort.key === "amount"
? a.amount - b.amount
: String(a[sort.key] ?? "").localeCompare(String(b[sort.key] ?? ""));
return cmp * factor;
});
}, [transactions, sort]);
const toggle = (key: SortKey) =>
setSort((s) =>
s.key === key
? { key, dir: s.dir === "asc" ? "desc" : "asc" }
: { key, dir: key === "date" || key === "amount" ? "desc" : "asc" },
);
if (transactions.length === 0) return <EmptyNote>No matching transactions.</EmptyNote>;
return (
<div className="flex flex-col gap-3">
<Subtle>
{formatRange(data.start_date, data.end_date)} · {data.count} transaction
{data.count === 1 ? "" : "s"}
{data.count > transactions.length && ` (showing ${transactions.length})`}
</Subtle>
<div className="overflow-x-auto rounded-xl border border-sand-light bg-warm-white">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-sand-light">
{COLUMNS.map((col) => {
const activeCol = sort.key === col.key;
const Icon = !activeCol ? ArrowUpDown : sort.dir === "asc" ? ArrowUp : ArrowDown;
return (
<th
key={col.key}
className={cn("px-2 py-2 first:pl-3 last:pr-3", col.align === "right" ? "text-right" : "text-left")}
aria-sort={activeCol ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
>
<button
onClick={() => toggle(col.key)}
className={cn(
"inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide cursor-pointer hover:text-charcoal",
activeCol ? "text-charcoal" : "text-warm-gray",
)}
>
{col.label}
<Icon size={11} className={activeCol ? "" : "opacity-40"} />
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{sorted.map((t, i) => (
<tr key={i} className="border-t border-sand-light/60 align-top">
<td className="whitespace-nowrap py-1.5 pl-3 pr-2 text-warm-gray">
{formatDate(t.date, { month: "short", day: "numeric" })}
</td>
<td className="px-2 py-1.5">
<div className="text-charcoal">{t.payee}</div>
{t.memo && <div className="text-[11px] text-warm-gray">{t.memo}</div>}
</td>
<td className="px-2 py-1.5 text-warm-gray">{t.category}</td>
<td
className={cn(
"whitespace-nowrap py-1.5 pl-2 pr-3 text-right font-semibold tabular-nums",
t.amount < 0 ? "text-charcoal" : "text-leaf-dark",
)}
>
{formatMoney(t.amount, { signed: true })}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-sand bg-sand-light/40 font-bold">
<td className="py-2 pl-3 pr-2 text-charcoal" colSpan={3}>
Total
</td>
<td
className={cn(
"whitespace-nowrap py-2 pl-2 pr-3 text-right tabular-nums",
data.total < 0 ? "text-charcoal" : "text-leaf-dark",
)}
>
{formatMoney(data.total, { signed: true })}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* USD with an explicit sign: "−$12.50" for negatives and, when `signed`,
* "+$12.50" for positives. Amounts are dollars, not YNAB milliunits.
*/
export function formatMoney(amount: number, { signed = false } = {}): string {
const value = Number.isFinite(amount) ? amount : 0;
const abs = usd.format(Math.abs(value));
if (value < 0) return `−${abs}`;
if (signed && value > 0) return `+${abs}`;
return abs;
}
/** Tailwind text colour for a money value: red when negative. */
export function moneyTone(amount: number): string {
return amount < 0 ? "text-red-600" : "text-charcoal";
}
/** Parse a bare "YYYY-MM[-DD]" as a local date (avoids the UTC off-by-one). */
function parseDate(value: string): Date | null {
const m = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(value);
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3] ?? 1));
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatDate(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" },
): string {
if (!value) return "";
const d = parseDate(value);
return d ? d.toLocaleDateString("en-US", options) : value;
}
/** "2026-10" or "2026-10-01" → "October 2026". */
export function formatMonth(value: string | null | undefined): string {
return formatDate(value, { month: "long", year: "numeric" });
}
export function formatRange(start?: string | null, end?: string | null): string {
if (start && end) return `${formatDate(start)} – ${formatDate(end)}`;
if (end) return `Through ${formatDate(end)}`;
return start ? `From ${formatDate(start)}` : "";
}
@@ -0,0 +1,84 @@
import React from "react";
import {
CalendarDays,
ChartColumn,
ChefHat,
NotebookText,
PiggyBank,
Receipt,
Search,
Shapes,
ShoppingCart,
Wallet,
type LucideIcon,
} from "lucide-react";
import type { Artifact } from "../../../api/artifactTypes";
import { MealieMealPlan } from "./MealieMealPlan";
import { MealieRecipe } from "./MealieRecipe";
import { MealieShoppingLists } from "./MealieShoppingLists";
import { ObsidianNote } from "./ObsidianNote";
import { SearchDocuments } from "./SearchDocuments";
import { UnknownArtifact } from "./UnknownArtifact";
import { YnabCategoryBudget } from "./YnabCategoryBudget";
import { YnabSpendingReport } from "./YnabSpendingReport";
import { YnabSummary } from "./YnabSummary";
import { YnabTransactions } from "./YnabTransactions";
import type { RendererProps } from "./types";
// biome-ignore lint/suspicious/noExplicitAny: each renderer narrows its own data shape
const RENDERERS: Record<string, React.ComponentType<RendererProps<any>>> = {
"ynab.summary": YnabSummary,
"ynab.category_budget": YnabCategoryBudget,
"ynab.spending_report": YnabSpendingReport,
"ynab.transactions": YnabTransactions,
"mealie.recipe": MealieRecipe,
"mealie.meal_plan": MealieMealPlan,
"mealie.shopping_lists": MealieShoppingLists,
"obsidian.note": ObsidianNote,
"search.documents": SearchDocuments,
};
const KIND_ICONS: Record<string, LucideIcon> = {
"ynab.summary": PiggyBank,
"ynab.category_budget": Wallet,
"ynab.spending_report": ChartColumn,
"ynab.transactions": Receipt,
"mealie.recipe": ChefHat,
"mealie.meal_plan": CalendarDays,
"mealie.shopping_lists": ShoppingCart,
"obsidian.note": NotebookText,
"search.documents": Search,
};
export const kindIcon = (kind: string): LucideIcon => KIND_ICONS[kind] ?? Shapes;
/** Falls back to the raw JSON if a renderer throws on an unexpected payload. */
class RenderBoundary extends React.Component<
{ kind: string; data: unknown; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.error(`Artifact renderer for ${this.props.kind} failed:`, error);
}
render() {
if (this.state.failed) return <UnknownArtifact kind={this.props.kind} data={this.props.data} />;
return this.props.children;
}
}
export const ArtifactRenderer = ({ artifact }: { artifact: Artifact }) => {
const Renderer = RENDERERS[artifact.kind];
if (!Renderer) return <UnknownArtifact kind={artifact.kind} data={artifact.data} />;
return (
<RenderBoundary key={artifact.id} kind={artifact.kind} data={artifact.data}>
<Renderer data={artifact.data} sourceRef={artifact.source_ref} />
</RenderBoundary>
);
};
@@ -0,0 +1,77 @@
import type React from "react";
import { ExternalLink } from "lucide-react";
import { cn } from "../../../lib/utils";
export const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-[11px] font-bold uppercase tracking-wider text-warm-gray mb-2">
{children}
</h3>
);
export const Subtle = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p className={cn("text-xs text-warm-gray", className)}>{children}</p>
);
export const Chip = ({
icon,
children,
tone = "sand",
}: {
icon?: React.ReactNode;
children: React.ReactNode;
tone?: "sand" | "leaf" | "amber";
}) => (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
tone === "sand" && "bg-sand-light/70 text-charcoal",
tone === "leaf" && "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
tone === "amber" && "bg-amber-pale text-amber-dark",
)}
>
{icon}
{children}
</span>
);
export const StatTile = ({
label,
value,
tone = "default",
}: {
label: string;
value: React.ReactNode;
tone?: "default" | "good" | "bad";
}) => (
<div className="rounded-xl border border-sand-light bg-warm-white px-3 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">{label}</div>
<div
className={cn(
"mt-0.5 text-lg font-bold tabular-nums",
tone === "default" && "text-charcoal",
tone === "good" && "text-leaf-dark",
tone === "bad" && "text-red-600",
)}
>
{value}
</div>
</div>
);
export const OpenExternal = ({ href, label }: { href: string; label: string }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-xl border border-sand bg-warm-white px-3 py-1.5 text-xs font-semibold text-leaf-dark hover:bg-leaf-pale hover:border-leaf-light transition-colors"
>
<ExternalLink size={13} />
{label}
</a>
);
export const EmptyNote = ({ children }: { children: React.ReactNode }) => (
<p className="rounded-xl border border-dashed border-sand px-3 py-4 text-center text-sm text-warm-gray">
{children}
</p>
);
@@ -0,0 +1,110 @@
// Per-kind `data` shapes (docs/cowork-ui-plan.md § V1 kinds). Renderers treat
// them as best-effort: optional chaining guards against partial payloads.
import type { ArtifactSourceRef } from "../../../api/artifactTypes";
export type SourceRef = ArtifactSourceRef;
export type MealieRecipeData = {
slug: string;
name: string;
description: string | null;
servings: string | number | null;
prep_time: string | null;
cook_time: string | null;
total_time: string | null;
ingredients: string[];
instructions: { title?: string; text: string }[];
tags: string[];
image_url?: string; // in the contract, not sent by the backend today
};
export type MealieMealPlanData = {
start_date: string;
end_date: string;
days: {
date: string;
meals: { entry_type: string; name: string; recipe_slug?: string; note?: string }[];
}[];
};
export type MealieShoppingListsData = {
lists: { name: string; items: { name: string; quantity: number | string | null; checked: boolean }[] }[];
};
export type ObsidianNoteData = {
path: string;
title: string;
markdown: string;
tags: string[];
frontmatter: Record<string, unknown>;
};
export type SearchDocumentsData = {
query: string;
source: string;
results: {
title: string;
snippet: string;
filename?: string;
path?: string;
document_type?: string;
date?: string;
}[];
};
export type YnabSummaryData = {
month: string;
ready_to_assign: number;
income: number;
assigned: number;
/** Positive for spending (-SUM of outflows). */
spent: number;
age_of_money?: number | null;
net_worth: number;
overspent: { name: string; available: number }[];
};
export type YnabTransactionsData = {
/** Null for open-ended searches ("all time through end_date"). */
start_date: string | null;
end_date: string;
count: number;
/** Net of outflows and inflows. */
total: number;
transactions: {
date: string;
payee: string;
category: string;
account: string;
memo?: string | null;
/** Signed: negative = outflow. */
amount: number;
}[];
};
export type YnabSpendingReportData = {
start_date: string;
end_date: string;
group_by: "category" | "category_group" | "payee" | "account" | "month" | string;
/** amount is positive for spending (-SUM); a net refund comes out negative. */
rows: { key: string; amount: number; count?: number }[];
total: number;
};
export type YnabCategoryBudgetData = {
month: string;
groups: {
name: string;
categories: {
name: string;
assigned: number;
/** Negative for spending. */
activity: number;
available: number;
goal_target?: number | null;
}[];
}[];
};
export type RendererProps<T> = { data: T; sourceRef: SourceRef | null };
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { artifactService } from "../../api/artifactService";
import type { Artifact, ArtifactRef } from "../../api/artifactTypes";
/** Windows at least this wide auto-open artifacts as tools produce them. */
export const AUTO_OPEN_MIN_WIDTH = 1280;
export type ArtifactEntry =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "ready"; artifact: Artifact };
export type ArtifactPanelView = "tab" | "library";
type OpenOptions = {
/** Set for automatic opens; they don't re-expand a panel the user collapsed. */
auto?: boolean;
};
/**
* State for the right-hand artifact panel: the open tabs, which one is
* active, and a per-id cache of fetched artifacts. Tabs reset whenever the
* conversation changes; the cache survives (artifact ids are unique).
*/
export function useArtifactPanel(conversationId: string | undefined) {
const [tabs, setTabs] = useState<ArtifactRef[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<ArtifactPanelView>("tab");
const [cache, setCache] = useState<Record<string, ArtifactEntry>>({});
const cacheRef = useRef(cache);
cacheRef.current = cache;
// Set when the user collapses the panel, so auto-opens stop forcing it open.
const userCollapsedRef = useRef(false);
useEffect(() => {
setTabs([]);
setActiveId(null);
setIsOpen(false);
setView("tab");
userCollapsedRef.current = false;
}, [conversationId]);
const load = useCallback(async (id: string) => {
setCache((prev) => ({ ...prev, [id]: { status: "loading" } }));
try {
const artifact = await artifactService.getArtifact(id);
setCache((prev) => ({ ...prev, [id]: { status: "ready", artifact } }));
} catch (err) {
const error = err instanceof Error ? err.message : "Failed to load artifact";
setCache((prev) => ({ ...prev, [id]: { status: "error", error } }));
}
}, []);
const open = useCallback(
(ref: ArtifactRef, { auto = false }: OpenOptions = {}) => {
setTabs((prev) => (prev.some((t) => t.id === ref.id) ? prev : [...prev, ref]));
setActiveId(ref.id);
setView("tab");
if (!auto || !userCollapsedRef.current) setIsOpen(true);
const cached = cacheRef.current[ref.id];
if (!cached || cached.status === "error") load(ref.id);
},
[load],
);
const close = useCallback(
(id: string) => {
const index = tabs.findIndex((t) => t.id === id);
if (index === -1) return;
const next = tabs.filter((t) => t.id !== id);
setTabs(next);
// Activate the neighbour (right, else left) when the active tab closes.
if (activeId === id) setActiveId((next[index] ?? next[index - 1])?.id ?? null);
if (next.length === 0) setView("library");
},
[tabs, activeId],
);
const retry = useCallback((id: string) => load(id), [load]);
const expand = useCallback(() => {
userCollapsedRef.current = false;
setIsOpen(true);
}, []);
const collapse = useCallback(() => {
userCollapsedRef.current = true;
setIsOpen(false);
}, []);
/** Open the panel on the list of this conversation's artifacts. */
const showLibrary = useCallback(() => {
userCollapsedRef.current = false;
setView("library");
setIsOpen(true);
}, []);
const active = tabs.find((t) => t.id === activeId) ?? null;
return {
tabs,
active,
activeEntry: active ? (cache[active.id] ?? { status: "loading" as const }) : null,
isOpen,
view,
setView,
open,
close,
retry,
expand,
collapse,
showLibrary,
};
}
export type ArtifactPanelState = ReturnType<typeof useArtifactPanel>;
+6
View File
@@ -45,6 +45,8 @@ type UseChatOptions = {
refreshConversations: () => Promise<void>; refreshConversations: () => Promise<void>;
onSessionExpired: () => void; onSessionExpired: () => void;
scrollToBottom: () => void; scrollToBottom: () => void;
/** Called when a tool in the streaming turn produced an artifact. */
onArtifact?: (ref: ArtifactRef, conversationId: string) => void;
}; };
export function useChat({ export function useChat({
@@ -52,6 +54,7 @@ export function useChat({
refreshConversations, refreshConversations,
onSessionExpired, onSessionExpired,
scrollToBottom, scrollToBottom,
onArtifact,
}: UseChatOptions) { }: UseChatOptions) {
// ChatScreen renders under a single catch-all route so it isn't remounted // ChatScreen renders under a single catch-all route so it isn't remounted
// when the first message navigates to /c/:id (which would abort the stream), // when the first message navigates to /c/:id (which would abort the stream),
@@ -69,6 +72,8 @@ export function useChat({
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null); const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null);
const stalledQueryRef = useRef<string | null>(null); const stalledQueryRef = useRef<string | null>(null);
const onArtifactRef = useRef(onArtifact);
onArtifactRef.current = onArtifact;
const thinkingStartRef = useRef<number | null>(null); const thinkingStartRef = useRef<number | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
@@ -247,6 +252,7 @@ export function useChat({
}; };
updateMessages((prev) => prev.concat([row])); updateMessages((prev) => prev.concat([row]));
} else if (event.type === "tool_end") { } else if (event.type === "tool_end") {
if (event.artifact) onArtifactRef.current?.(event.artifact, activeId);
const now = Date.now(); const now = Date.now();
updateMessages((prev) => { updateMessages((prev) => {
// Pair by run_id; a legacy end (no run_id) pairs with the // Pair by run_id; a legacy end (no run_id) pairs with the
@@ -0,0 +1,90 @@
{
"kind": "mealie.meal_plan",
"title": "Meal plan · Oct 12–18",
"data": {
"start_date": "2026-10-12",
"end_date": "2026-10-18",
"days": [
{
"date": "2026-10-12",
"meals": [
{
"entry_type": "breakfast",
"name": "Overnight oats",
"recipe_slug": "overnight-oats"
},
{
"entry_type": "dinner",
"name": "Chicken Tikka Masala",
"recipe_slug": "chicken-tikka-masala"
}
]
},
{
"date": "2026-10-13",
"meals": [
{
"entry_type": "dinner",
"name": "Leftovers",
"note": "Tikka masala + extra rice"
}
]
},
{
"date": "2026-10-14",
"meals": [
{
"entry_type": "lunch",
"name": "Lentil soup",
"recipe_slug": "lentil-soup"
},
{
"entry_type": "dinner",
"name": "Sheet-pan gnocchi",
"recipe_slug": "sheet-pan-gnocchi"
}
]
},
{
"date": "2026-10-16",
"meals": [
{
"entry_type": "dinner",
"name": "Taco night",
"note": "Pick up tortillas"
},
{
"entry_type": "side",
"name": "Elote salad",
"recipe_slug": "elote-salad"
}
]
},
{
"date": "2026-10-17",
"meals": [
{
"entry_type": "dinner",
"name": "Dinner out",
"note": "Burma Superstar 7pm"
}
]
},
{
"date": "2026-10-18",
"meals": [
{
"entry_type": "breakfast",
"name": "Dutch baby",
"recipe_slug": "dutch-baby"
},
{
"entry_type": "dinner",
"name": "Mushroom risotto",
"recipe_slug": "mushroom-risotto"
}
]
}
]
}
}
@@ -0,0 +1,56 @@
{
"kind": "mealie.recipe",
"title": "Chicken Tikka Masala",
"source_ref": {
"system": "mealie",
"id": "chicken-tikka-masala",
"url": "https://mealie.example.lan/g/home/r/chicken-tikka-masala"
},
"data": {
"slug": "chicken-tikka-masala",
"name": "Chicken Tikka Masala",
"description": "Weeknight tikka masala with yogurt-marinated chicken thighs and a creamy tomato sauce.",
"servings": "4 servings",
"prep_time": "20 minutes",
"cook_time": "35 minutes",
"total_time": "55 minutes",
"ingredients": [
"1.5 lb boneless chicken thighs, cut into chunks",
"1 cup plain whole-milk yogurt",
"2 tbsp garam masala, divided",
"1 tbsp lemon juice",
"2 tbsp ghee or neutral oil",
"1 large onion, finely diced",
"4 cloves garlic, minced",
"1 tbsp grated ginger",
"1 can (28 oz) crushed tomatoes",
"3/4 cup heavy cream",
"1 tsp kosher salt",
"Cilantro and basmati rice, to serve"
],
"instructions": [
{
"title": "Marinate",
"text": "Mix the yogurt, 1 tbsp garam masala, lemon juice and salt. Toss with the chicken and rest at least 15 minutes."
},
{
"text": "Broil the chicken on a sheet pan for 8–10 minutes until charred at the edges."
},
{
"title": "Sauce",
"text": "Melt the ghee in a large pan. Cook the onion until golden, about 8 minutes, then add the garlic, ginger and the rest of the garam masala for 1 minute."
},
{
"text": "Add the tomatoes and simmer 15 minutes. Stir in the cream and the chicken and simmer 5 more minutes."
},
{
"text": "Finish with cilantro and serve over rice."
}
],
"tags": [
"Indian",
"Weeknight",
"Freezer friendly"
]
}
}
@@ -0,0 +1,67 @@
{
"kind": "mealie.shopping_lists",
"title": "Shopping lists",
"data": {
"lists": [
{
"name": "Groceries",
"items": [
{
"name": "Chicken thighs",
"quantity": 1.5,
"checked": false
},
{
"name": "Crushed tomatoes (28 oz)",
"quantity": 1.0,
"checked": false
},
{
"name": "Heavy cream",
"quantity": 1.0,
"checked": false
},
{
"name": "Tortillas",
"quantity": 2.0,
"checked": false
},
{
"name": "Whole-milk yogurt",
"quantity": 1.0,
"checked": true
},
{
"name": "Cilantro",
"quantity": 1.0,
"checked": true
}
]
},
{
"name": "Costco",
"items": [
{
"name": "Cat litter",
"quantity": 2.0,
"checked": false
},
{
"name": "Olive oil",
"quantity": 1.0,
"checked": false
},
{
"name": "Paper towels",
"quantity": 1.0,
"checked": true
}
]
},
{
"name": "Pet store",
"items": []
}
]
}
}
@@ -0,0 +1,24 @@
{
"kind": "obsidian.note",
"title": "2026-09 annual checkup",
"source_ref": {
"system": "obsidian",
"path": "Simba/Vet/2026-09 annual checkup.md",
"url": "obsidian://open?vault=Home&file=Simba%2FVet%2F2026-09%20annual%20checkup.md"
},
"data": {
"path": "Simba/Vet/2026-09 annual checkup.md",
"title": "2026-09 annual checkup",
"tags": [
"simba",
"vet",
"health"
],
"frontmatter": {
"date": "2026-09-18",
"vet": "Mission Pet Hospital",
"weight_lb": 11.4
},
"markdown": "# Annual checkup\n\n**Vet:** Dr. Patel, Mission Pet Hospital \n**Weight:** 11.4 lb (down from 11.9)\n\n## Notes\n\n- Teeth look good; mild tartar on the upper molars.\n- Weight loss is fine, but keep an eye on it. Re-weigh in 3 months.\n- Vaccines: FVRCP booster given. Rabies is due **2027-03**.\n\n## Follow-ups\n\n- [x] Book dental cleaning quote\n- [ ] Switch to the weight-management kibble\n- [ ] Re-weigh in December\n\n| Item | Cost |\n|---|---|\n| Exam | $72.00 |\n| FVRCP | $38.00 |\n| **Total** | **$110.00** |\n\n> Simba was a perfect gentleman until the thermometer.\n"
}
}
@@ -0,0 +1,31 @@
{
"kind": "search.documents",
"title": "Search: Simba vaccination records",
"data": {
"query": "Simba vaccination records",
"source": "paperless",
"results": [
{
"title": "2026-09-18_mission-pet-hospital",
"snippet": "Patient: Simba (feline, DSH, M/N). Services: annual wellness exam, FVRCP vaccine (3-year), fecal test. Next due: rabies 03/2027. Total $110.00.",
"filename": "2026-09-18_mission-pet-hospital.pdf",
"document_type": "Invoice",
"date": "2026-09-18"
},
{
"title": "rabies-certificate-2024",
"snippet": "This certifies that Simba has been vaccinated against rabies. Vaccine: Purevax Feline Rabies, lot 3K91A. Date given 03/14/2024. Expires 03/14/2027.",
"filename": "rabies-certificate-2024.pdf",
"document_type": "Certificate",
"date": "2024-03-14"
},
{
"title": "2022-05-09_sf-spca-adoption",
"snippet": "Simba, approx. 10 weeks. Vaccines given: FVRCP #1 (2022-05-02), deworming. Microchip 985112004567213 registered to adopter.",
"filename": "2022-05-09_sf-spca-adoption.pdf",
"document_type": "Pet records",
"date": "2022-05-09"
}
]
}
}
@@ -0,0 +1,105 @@
{
"kind": "ynab.category_budget",
"title": "Category budget · October 2026",
"data": {
"month": "2026-10",
"groups": [
{
"name": "Bills",
"categories": [
{
"name": "Rent",
"assigned": 2400.0,
"activity": -2400.0,
"available": 0.0,
"goal_target": 2400.0
},
{
"name": "Electric",
"assigned": 120.0,
"activity": -84.31,
"available": 35.69
},
{
"name": "Internet",
"assigned": 70.0,
"activity": -70.0,
"available": 0.0,
"goal_target": 70.0
},
{
"name": "Phone",
"assigned": 85.0,
"activity": 0.0,
"available": 85.0
}
]
},
{
"name": "Everyday",
"categories": [
{
"name": "Groceries",
"assigned": 650.0,
"activity": -412.88,
"available": 237.12,
"goal_target": 650.0
},
{
"name": "Dining Out",
"assigned": 200.0,
"activity": -286.42,
"available": -86.42
},
{
"name": "Transportation",
"assigned": 180.0,
"activity": -61.2,
"available": 118.8
}
]
},
{
"name": "Simba",
"categories": [
{
"name": "Pet Food",
"assigned": 90.0,
"activity": -64.99,
"available": 25.01
},
{
"name": "Pet Care",
"assigned": 60.0,
"activity": -83.1,
"available": -23.1
},
{
"name": "Litter",
"assigned": 40.0,
"activity": -18.49,
"available": 21.51
}
]
},
{
"name": "Savings",
"categories": [
{
"name": "Emergency Fund",
"assigned": 500.0,
"activity": 0.0,
"available": 8250.0,
"goal_target": 10000.0
},
{
"name": "Vacation",
"assigned": 250.0,
"activity": 0.0,
"available": 1340.0
}
]
}
]
}
}
@@ -0,0 +1,57 @@
{
"kind": "ynab.spending_report",
"title": "Spending by category · Sep 1–30",
"data": {
"start_date": "2026-09-01",
"end_date": "2026-09-30",
"group_by": "category",
"total": 3642.21,
"rows": [
{
"key": "Rent",
"amount": 2400.0,
"count": 1
},
{
"key": "Groceries",
"amount": 588.14,
"count": 14
},
{
"key": "Dining Out",
"amount": 243.7,
"count": 9
},
{
"key": "Pet Care",
"amount": 148.0,
"count": 2
},
{
"key": "Electric",
"amount": 97.55,
"count": 1
},
{
"key": "Transportation",
"amount": 74.32,
"count": 5
},
{
"key": "Internet",
"amount": 70.0,
"count": 1
},
{
"key": "Pet Food",
"amount": 62.5,
"count": 2
},
{
"key": "Clothing",
"amount": -42.0,
"count": 2
}
]
}
}
@@ -0,0 +1,27 @@
{
"kind": "ynab.summary",
"title": "Budget · October 2026",
"data": {
"month": "2026-10",
"ready_to_assign": 412.37,
"income": 7850.0,
"assigned": 7437.63,
"spent": 3921.48,
"age_of_money": 41,
"net_worth": 84213.92,
"overspent": [
{
"name": "Dining Out",
"available": -86.42
},
{
"name": "Pet Care",
"available": -23.1
},
{
"name": "Gifts",
"available": -5.99
}
]
}
}
@@ -0,0 +1,78 @@
{
"kind": "ynab.transactions",
"title": "Transactions · Sep 12–Oct 12",
"data": {
"start_date": "2026-09-12",
"end_date": "2026-10-12",
"count": 9,
"total": -286.42,
"transactions": [
{
"date": "2026-10-10",
"payee": "Tartine Bakery",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -18.75
},
{
"date": "2026-10-08",
"payee": "Burma Superstar",
"category": "Dining Out",
"account": "Chase Sapphire",
"memo": "Dinner with Alex",
"amount": -72.4
},
{
"date": "2026-10-05",
"payee": "Philz Coffee",
"category": "Dining Out",
"account": "Checking",
"amount": -6.25
},
{
"date": "2026-10-03",
"payee": "Souvla",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -34.1
},
{
"date": "2026-09-30",
"payee": "Venmo",
"category": "Dining Out",
"account": "Checking",
"memo": "Split pizza refund",
"amount": 15.0
},
{
"date": "2026-09-27",
"payee": "Zuni Cafe",
"category": "Dining Out",
"account": "Chase Sapphire",
"memo": "Anniversary",
"amount": -121.32
},
{
"date": "2026-09-22",
"payee": "Philz Coffee",
"category": "Dining Out",
"account": "Checking",
"amount": -6.25
},
{
"date": "2026-09-18",
"payee": "Golden Boy Pizza",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -29.6
},
{
"date": "2026-09-14",
"payee": "Tartine Bakery",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -12.75
}
]
}
}
@@ -12,11 +12,19 @@
// "error" a call that fails, then an answer // "error" a call that fails, then an answer
// "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool} // "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool}
// "plain" no tools, just a streamed answer // "plain" no tools, just a streamed answer
// artifact panel scenarios, one tool call each with that kind's fixture:
// "recipe" | "meal" | "shopping" | "note" | "vaccin" (search.documents) |
// "budget" (summary + category budget, newest wins) | "by category"
// (spending report) | "trend" (month-grouped spending) | "transactions" |
// "philz" (open-ended transactions, start_date null) | "everything"
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
// (unknown kind → JSON fallback)
// anything else: two sequential calls, then a streamed answer // anything else: two sequential calls, then a streamed answer
// A conversation with id "seeded" exists up front with saved tool messages. // Conversations "seeded" (timeline) and "everything" (one saved tool message
// Every *.json in this directory with `kind` and `data` is loaded as an // per artifact kind) exist up front.
// artifact (`id` defaults to the file name), so per-kind fixtures can be // artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
// dropped in alongside this file. // backend tools emit. Any other *.json in this directory with `kind` and
// `data` is loaded as an artifact of "seeded" (`id` defaults to the file name).
// MOCK_DELAY_SCALE=0.2 speeds the stream up; 0 makes it instant. // MOCK_DELAY_SCALE=0.2 speeds the stream up; 0 makes it instant.
import http from "node:http"; import http from "node:http";
@@ -67,27 +75,28 @@ function addArtifact(conversationId, tool, a) {
message_id: a.message_id ?? null, message_id: a.message_id ?? null,
created_at: a.created_at ?? now(), created_at: a.created_at ?? now(),
}; };
if (a.broken) record.broken = true; // GET /api/artifacts/<id> answers 500
artifacts.set(record.id, record); artifacts.set(record.id, record);
return record; return record;
} }
const refOf = (a) => (a ? { id: a.id, kind: a.kind, title: a.title } : null); const refOf = (a) => (a ? { id: a.id, kind: a.kind, title: a.title } : null);
// Spending amounts are positive (-SUM of outflows), as the backend sends them.
const SPENDING = { const SPENDING = {
kind: "ynab.spending_report", kind: "ynab.spending_report",
title: "October spending by category", title: "Spending by category · Oct 1–12",
data: { data: {
start_date: "2026-10-01", start_date: "2026-10-01",
end_date: "2026-10-12", end_date: "2026-10-12",
group_by: "category", group_by: "category",
rows: [ rows: [
{ key: "Groceries", amount: -412.18, count: 9 }, { key: "Groceries", amount: 412.18, count: 9 },
{ key: "Dining Out", amount: -186.4, count: 6 }, { key: "Dining Out", amount: 186.4, count: 6 },
{ key: "Cat Supplies", amount: -74.99, count: 2 }, { key: "Cat Supplies", amount: 74.99, count: 2 },
], ],
total: -673.57, total: 673.57,
}, },
source_ref: { system: "ynab" },
}; };
const RECIPE = { const RECIPE = {
@@ -168,8 +177,88 @@ function loadFixtureArtifacts() {
} }
} }
seed(); // Per-kind fixtures (artifacts/<kind>.json) plus a few variants.
loadFixtureArtifacts(); const FIXTURES = {};
for (const f of fs.readdirSync(path.join(here, "artifacts"))) {
if (!f.endsWith(".json")) continue;
const a = JSON.parse(fs.readFileSync(path.join(here, "artifacts", f), "utf8"));
FIXTURES[a.kind] = a;
}
const EXTRA_FIXTURES = {
spendingByMonth: {
kind: "ynab.spending_report",
title: "Spending by month · Jan 1–Oct 12",
data: {
start_date: "2026-01-01",
end_date: "2026-10-12",
group_by: "month",
total: 2381.94,
rows: [
{ key: "2026-01", amount: 188.4, count: 7 },
{ key: "2026-02", amount: 302.55, count: 11 },
{ key: "2026-03", amount: 241.1, count: 8 },
{ key: "2026-04", amount: 150.0, count: 5 },
{ key: "2026-05", amount: 276.9, count: 9 },
{ key: "2026-06", amount: 333.12, count: 12 },
{ key: "2026-07", amount: 198.45, count: 6 },
{ key: "2026-08", amount: 221.3, count: 8 },
{ key: "2026-09", amount: 183.7, count: 7 },
{ key: "2026-10", amount: 286.42, count: 9 },
],
},
},
// Open-ended search (payee filter, no start date): start_date is null.
openEndedTransactions: {
kind: "ynab.transactions",
title: "Transactions · through 2026-10-12",
data: {
start_date: null,
end_date: "2026-10-12",
count: 3,
total: -18.75,
transactions: [
{ date: "2026-10-05", payee: "Philz Coffee", category: "Dining Out", account: "Checking", amount: -6.25 },
{ date: "2026-09-22", payee: "Philz Coffee", category: "Dining Out", account: "Checking", amount: -6.25 },
{ date: "2026-08-30", payee: "Philz Coffee", category: "Dining Out", account: "Checking", memo: "Beans", amount: -6.25 },
],
},
},
mystery: {
kind: "homebox.item",
title: "Cat tree (Homebox)",
data: { name: "Cat tree", location: "Living room", quantity: 1, labels: ["simba"] },
},
};
const TOOL_FOR_KIND = {
"mealie.recipe": "mealie_get_recipe",
"mealie.meal_plan": "mealie_meal_plans",
"mealie.shopping_lists": "mealie_shopping_lists",
"obsidian.note": "obsidian_read_note",
"search.documents": "simba_search",
"ynab.summary": "ynab_budget_summary",
"ynab.transactions": "ynab_search_transactions",
"ynab.spending_report": "ynab_spending_report",
"ynab.category_budget": "ynab_category_budget",
};
const toolFor = (a) => TOOL_FOR_KIND[a.kind] ?? "homebox_get_item";
// A conversation holding one saved tool message per artifact kind, for the
// panel's library and reload checks.
function seedEverything() {
const c = newConversation("everything", "Everything Simba knows");
pushMessage(c, { speaker: "user", text: "Show me everything" });
let t = Date.parse("2026-10-12T15:00:00Z");
for (const fx of [...Object.values(FIXTURES), ...Object.values(EXTRA_FIXTURES)]) {
const tool = toolFor(fx);
const msg = pushMessage(c, { speaker: "tool", text: `[${tool} result]`, tool, label: label(tool), artifact: null });
const a = addArtifact(c.id, tool, { ...fx, message_id: msg.id, created_at: new Date((t += 60_000)).toISOString() });
msg.artifact = refOf(a);
}
pushMessage(c, { speaker: "simba", text: "Here's everything I could find — open any artifact from the panel." });
}
// ---------------------------------------------------------------- SSE scenarios // ---------------------------------------------------------------- SSE scenarios
@@ -180,9 +269,37 @@ const LABELS = {
obsidian_search_notes: "Searching notes…", obsidian_search_notes: "Searching notes…",
web_search: "Searching the web…", web_search: "Searching the web…",
simba_search: "Searching Simba's records…", simba_search: "Searching Simba's records…",
mealie_meal_plans: "Checking the meal plan…",
mealie_shopping_lists: "Checking shopping lists…",
obsidian_read_note: "Reading note…",
ynab_budget_summary: "Checking budget…",
ynab_search_transactions: "Looking up transactions…",
ynab_category_budget: "Checking category budgets…",
}; };
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`; const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
seed();
seedEverything();
loadFixtureArtifacts();
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
function artifactScenario(q) {
if (q.includes("everything")) return Object.keys(TOOL_FOR_KIND).map((k) => FIXTURES[k]);
if (q.includes("broken")) return [{ ...FIXTURES["ynab.summary"], title: "Broken artifact", broken: true }];
if (q.includes("mystery")) return [EXTRA_FIXTURES.mystery];
if (q.includes("trend")) return [EXTRA_FIXTURES.spendingByMonth];
if (q.includes("philz")) return [EXTRA_FIXTURES.openEndedTransactions];
if (q.includes("budget")) return [FIXTURES["ynab.summary"], FIXTURES["ynab.category_budget"]];
if (q.includes("recipe")) return [FIXTURES["mealie.recipe"]];
if (q.includes("meal")) return [FIXTURES["mealie.meal_plan"]];
if (q.includes("shopping")) return [FIXTURES["mealie.shopping_lists"]];
if (q.includes("note")) return [FIXTURES["obsidian.note"]];
if (q.includes("vaccin")) return [FIXTURES["search.documents"]];
if (q.includes("by category")) return [FIXTURES["ynab.spending_report"]];
if (q.includes("transactions")) return [FIXTURES["ynab.transactions"]];
return null;
}
/** /**
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded * Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
* into contract-shaped events; ctx persists tool messages and artifacts. * into contract-shaped events; ctx persists tool messages and artifacts.
@@ -222,6 +339,17 @@ function scenario(query) {
...answer("I couldn't search your notes, but a couple of vets nearby are open on Sundays."), ...answer("I couldn't search your notes, but a couple of vets nearby are open on Sundays."),
]; ];
} }
const artifactsForQuery = artifactScenario(q);
if (artifactsForQuery) {
return [
[250, { type: "thinking" }],
...artifactsForQuery.flatMap((fx, i) => [
[200, { start: `a${i}`, tool: toolFor(fx), input: { query: query.slice(0, 200) } }],
[500, { end: `a${i}`, artifact: fx }],
]),
...answer("Here's what I found. Let me know if you want more detail."),
];
}
return [ return [
[300, { type: "thinking" }], [300, { type: "thinking" }],
[200, { type: "thinking_delta", delta: "Need today's date before the budget lookup." }], [200, { type: "thinking_delta", delta: "Need today's date before the budget lookup." }],
@@ -376,6 +504,7 @@ async function api(req, res, url) {
} }
if (p === "/api/artifacts") { if (p === "/api/artifacts") {
await sleep(300); // long enough to see the panel's loading states
const cid = url.searchParams.get("conversation_id"); const cid = url.searchParams.get("conversation_id");
return json( return json(
res, res,
@@ -387,8 +516,12 @@ async function api(req, res, url) {
} }
const art = p.match(/^\/api\/artifacts\/([^/]+)$/); const art = p.match(/^\/api\/artifacts\/([^/]+)$/);
if (art) { if (art) {
await sleep(400);
const a = artifacts.get(art[1]); const a = artifacts.get(art[1]);
return a ? json(res, 200, a) : json(res, 404, { error: "not found" }); if (!a) return json(res, 404, { error: "not found" });
if (a.broken) return json(res, 500, { error: "Internal error" });
const { broken, ...artifact } = a;
return json(res, 200, artifact);
} }
return json(res, 404, { error: `mock: no route for ${m} ${p}` }); return json(res, 404, { error: `mock: no route for ${m} ${p}` });