Cowork D: artifact side panel with per-kind renderers #65
@@ -202,3 +202,11 @@ body {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { useMatch, useNavigate } from "react-router-dom";
|
||||
import { Library, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ActivityTimeline } from "./ActivityTimeline";
|
||||
@@ -9,6 +9,8 @@ import { MessageInput } from "./MessageInput";
|
||||
import { SidebarContent } from "./SidebarContent";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
import { ArtifactPanel } from "./artifacts/ArtifactPanel";
|
||||
import { AUTO_OPEN_MIN_WIDTH, useArtifactPanel } from "./artifacts/useArtifactPanel";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useConversations } from "../hooks/useConversations";
|
||||
import { useChat, type ToolMessage } from "../hooks/useChat";
|
||||
@@ -51,6 +53,31 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
|
||||
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 {
|
||||
messages,
|
||||
isLoading,
|
||||
@@ -72,11 +99,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
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
|
||||
isLoadingRef.current = isLoading;
|
||||
|
||||
@@ -209,15 +234,52 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
asksimba
|
||||
</h1>
|
||||
</div>
|
||||
<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 className="flex items-center gap-1">
|
||||
{conversationId && (
|
||||
<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={toggleArtifacts}
|
||||
aria-label="Artifacts"
|
||||
>
|
||||
<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>
|
||||
|
||||
{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 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||
<div className="relative">
|
||||
@@ -270,7 +332,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
// The trailing block of an in-flight turn stays
|
||||
// "working" until the answer starts streaming.
|
||||
active={isLoading && end === messages.length}
|
||||
onOpenArtifact={handleOpenArtifact}
|
||||
onOpenArtifact={openArtifact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -378,6 +440,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ArtifactPanel panel={artifactPanel} conversationId={conversationId} />
|
||||
</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'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't load this conversation'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>;
|
||||
@@ -45,6 +45,8 @@ type UseChatOptions = {
|
||||
refreshConversations: () => Promise<void>;
|
||||
onSessionExpired: () => void;
|
||||
scrollToBottom: () => void;
|
||||
/** Called when a tool in the streaming turn produced an artifact. */
|
||||
onArtifact?: (ref: ArtifactRef, conversationId: string) => void;
|
||||
};
|
||||
|
||||
export function useChat({
|
||||
@@ -52,6 +54,7 @@ export function useChat({
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
scrollToBottom,
|
||||
onArtifact,
|
||||
}: UseChatOptions) {
|
||||
// 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),
|
||||
@@ -69,6 +72,8 @@ export function useChat({
|
||||
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null);
|
||||
|
||||
const stalledQueryRef = useRef<string | null>(null);
|
||||
const onArtifactRef = useRef(onArtifact);
|
||||
onArtifactRef.current = onArtifact;
|
||||
const thinkingStartRef = useRef<number | null>(null);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -247,6 +252,7 @@ export function useChat({
|
||||
};
|
||||
updateMessages((prev) => prev.concat([row]));
|
||||
} else if (event.type === "tool_end") {
|
||||
if (event.artifact) onArtifactRef.current?.(event.artifact, activeId);
|
||||
const now = Date.now();
|
||||
updateMessages((prev) => {
|
||||
// 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
|
||||
// "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool}
|
||||
// "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
|
||||
// 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.
|
||||
// Conversations "seeded" (timeline) and "everything" (one saved tool message
|
||||
// per artifact kind) exist up front.
|
||||
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
|
||||
// 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.
|
||||
|
||||
import http from "node:http";
|
||||
@@ -67,27 +75,28 @@ function addArtifact(conversationId, tool, a) {
|
||||
message_id: a.message_id ?? null,
|
||||
created_at: a.created_at ?? now(),
|
||||
};
|
||||
if (a.broken) record.broken = true; // GET /api/artifacts/<id> answers 500
|
||||
artifacts.set(record.id, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
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 = {
|
||||
kind: "ynab.spending_report",
|
||||
title: "October spending by category",
|
||||
title: "Spending by category · Oct 1–12",
|
||||
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 },
|
||||
{ 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,
|
||||
total: 673.57,
|
||||
},
|
||||
source_ref: { system: "ynab" },
|
||||
};
|
||||
|
||||
const RECIPE = {
|
||||
@@ -168,8 +177,88 @@ function loadFixtureArtifacts() {
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
loadFixtureArtifacts();
|
||||
// Per-kind fixtures (artifacts/<kind>.json) plus a few variants.
|
||||
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
|
||||
|
||||
@@ -180,9 +269,37 @@ const LABELS = {
|
||||
obsidian_search_notes: "Searching notes…",
|
||||
web_search: "Searching the web…",
|
||||
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}…`;
|
||||
|
||||
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
|
||||
* 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."),
|
||||
];
|
||||
}
|
||||
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 [
|
||||
[300, { type: "thinking" }],
|
||||
[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") {
|
||||
await sleep(300); // long enough to see the panel's loading states
|
||||
const cid = url.searchParams.get("conversation_id");
|
||||
return json(
|
||||
res,
|
||||
@@ -387,8 +516,12 @@ async function api(req, res, url) {
|
||||
}
|
||||
const art = p.match(/^\/api\/artifacts\/([^/]+)$/);
|
||||
if (art) {
|
||||
await sleep(400);
|
||||
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}` });
|
||||
|
||||
Reference in New Issue
Block a user