diff --git a/raggr-frontend/src/App.css b/raggr-frontend/src/App.css index 9670d2e..6f368e7 100644 --- a/raggr-frontend/src/App.css +++ b/raggr-frontend/src/App.css @@ -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; +} diff --git a/raggr-frontend/src/api/artifactService.ts b/raggr-frontend/src/api/artifactService.ts new file mode 100644 index 0000000..3d2c707 --- /dev/null +++ b/raggr-frontend/src/api/artifactService.ts @@ -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 { + 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 { + 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(); diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 9a833e6..6248a53 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -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 - +
+ {conversationId && ( + + )} + +
+ {conversationId && ( +
+

+ {selectedConversation?.title} +

+ +
+ )} + {messages.length === 0 && !messagesLoading ? (
@@ -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) => { )}
+ +
); }; diff --git a/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx b/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx new file mode 100644 index 0000000..fb65e5f --- /dev/null +++ b/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx @@ -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 ; + } + return ( + + ); + } + + if (!isOpen) return null; + return ( +
+
+
+
+ +
+ } closeLabel="Close artifacts" /> + +
+
+ ); +}; + +const CollapsedRail = ({ panel }: { panel: ArtifactPanelState }) => ( + +); + +const PanelHeader = ({ + panel, + closeIcon, + closeLabel, +}: { + panel: ArtifactPanelState; + closeIcon: ReactNode; + closeLabel: string; +}) => { + const libraryActive = panel.view === "library" || !panel.active; + const stripRef = useRef(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 ( +
+
+ {panel.tabs.length === 0 && ( + + Artifacts + + )} + {panel.tabs.map((tab) => ( + panel.open(tab)} + onClose={() => panel.close(tab.id)} + /> + ))} +
+ + +
+ ); +}; + +const Tab = ({ + tab, + selected, + onSelect, + onClose, +}: { + tab: ArtifactRef; + selected: boolean; + onSelect: () => void; + onClose: () => void; +}) => { + const Icon = kindIcon(tab.kind); + return ( +
+ + +
+ ); +}; + +const PanelBody = ({ + panel, + conversationId, +}: { + panel: ArtifactPanelState; + conversationId: string | undefined; +}) => { + const { active, activeEntry } = panel; + if (panel.view === "library" || !active || !activeEntry) { + return ( +
+ +
+ ); + } + + return ( +
+ {activeEntry.status === "loading" && ( +
+ + Loading {active.title}… +
+ )} + {activeEntry.status === "error" && ( +
+ +
+

Couldn't load this artifact

+

{activeEntry.error}

+
+ +
+ )} + {activeEntry.status === "ready" && ( +
+

+ {activeEntry.artifact.title} +

+ +
+ )} +
+ ); +}; + +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({ 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 ( +
    + {[0, 1, 2].map((i) => ( +
  • + ))} +
+ ); + } + + if (state.status === "error") { + return ( +
+ +

Couldn't load this conversation's artifacts.

+ +
+ ); + } + + if (state.items.length === 0) { + return ( +
+
+ +
+

No artifacts yet

+

+ When Simba pulls up a recipe, a budget, a note or some documents, it shows up here. +

+
+ ); + } + + const openIds = new Set(panel.tabs.map((t) => t.id)); + return ( +
+

+ In this conversation · {state.items.length} +

+
    + {state.items.map((item) => { + const Icon = kindIcon(item.kind); + return ( +
  • + +
  • + ); + })} +
+
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/MealieMealPlan.tsx b/raggr-frontend/src/components/artifacts/renderers/MealieMealPlan.tsx new file mode 100644 index 0000000..b667545 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/MealieMealPlan.tsx @@ -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) => { + const days = [...(data.days ?? [])].sort((a, b) => a.date.localeCompare(b.date)); + const today = todayIso(); + return ( +
+ {formatRange(data.start_date, data.end_date)} + {days.length === 0 ? ( + Nothing planned. + ) : ( +
    + {days.map((day) => ( +
  1. +
    + + {formatDate(day.date, { weekday: "long" })} + + + {formatDate(day.date, { month: "short", day: "numeric" })} + + {day.date === today && ( + Today + )} +
    + {day.meals.length === 0 ? ( +

    Nothing planned

    + ) : ( +
      + {day.meals.map((meal, i) => ( +
    • + + {meal.entry_type} + + + {meal.name} + {meal.note && {meal.note}} + +
    • + ))} +
    + )} +
  2. + ))} +
+ )} +
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/MealieRecipe.tsx b/raggr-frontend/src/components/artifacts/renderers/MealieRecipe.tsx new file mode 100644 index 0000000..6ba9a7e --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/MealieRecipe.tsx @@ -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) => { + const [ticked, setTicked] = useState>(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 ( +
+
+ {data.image_url && !imageFailed && ( + {data.name} 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 &&

{data.description}

} +
+ {times.map(([label, value]) => ( + }> + {label} {value} + + ))} + {data.servings != null && data.servings !== "" && ( + }>Serves {data.servings} + )} +
+ {(data.tags?.length ?? 0) > 0 && ( +
+ {data.tags.map((t) => ( + + {t} + + ))} +
+ )} + {sourceRef?.url && ( +
+ +
+ )} +
+ + {ingredients.length > 0 && ( +
+ + Ingredients + {ticked.size > 0 && ( + + · {ticked.size}/{ingredients.length} + + )} + +
    + {ingredients.map((ing, i) => ( +
  • + +
  • + ))} +
+
+ )} + + {instructions.length > 0 && ( +
+ Steps +
    + {instructions.map((step, i) => ( +
  1. + + {i + 1} + +
    + {step.title &&
    {step.title}
    } + {step.text} +
    +
  2. + ))} +
+
+ )} +
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx b/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx new file mode 100644 index 0000000..17285dc --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx @@ -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) => { + const lists = data.lists ?? []; + if (lists.length === 0) return No shopping lists.; + return ( +
+ {lists.map((list) => { + const done = list.items.filter((i) => i.checked).length; + return ( +
+
+

{list.name}

+ + {done}/{list.items.length} done + +
+ {list.items.length === 0 ? ( +

Empty list

+ ) : ( +
    + {list.items.map((item, i) => ( +
  • + + {item.checked && } + + + {item.name} + + {/* 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 && ( + {item.quantity} + )} +
  • + ))} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/ObsidianNote.tsx b/raggr-frontend/src/components/artifacts/renderers/ObsidianNote.tsx new file mode 100644 index 0000000..0c413a0 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/ObsidianNote.tsx @@ -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) => ( +
+
+ {data.path} + {(data.tags?.length ?? 0) > 0 && ( +
+ {data.tags.map((t) => ( + + #{t} + + ))} +
+ )} + {sourceRef?.url && ( +
+ +
+ )} +
+ {/* Tailwind's preflight strips list markers; notes need them back. */} +
+ {data.markdown ?? ""} +
+
+); diff --git a/raggr-frontend/src/components/artifacts/renderers/SearchDocuments.tsx b/raggr-frontend/src/components/artifacts/renderers/SearchDocuments.tsx new file mode 100644 index 0000000..730cf62 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/SearchDocuments.tsx @@ -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) => { + const results = data.results ?? []; + return ( +
+ + “{data.query}” in {data.source} · {results.length} result{results.length === 1 ? "" : "s"} + + {results.length === 0 ? ( + No matching documents. + ) : ( +
    + {results.map((r, i) => { + const location = r.filename ?? r.path; + return ( +
  • +
    +

    {r.title}

    + {r.document_type && ( + + {r.document_type} + + )} +
    +

    {r.snippet}

    + {(location || r.date) && ( +
    + {location && ( + + + {location} + + )} + {r.date && {formatDate(r.date)}} +
    + )} +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/UnknownArtifact.tsx b/raggr-frontend/src/components/artifacts/renderers/UnknownArtifact.tsx new file mode 100644 index 0000000..5800298 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/UnknownArtifact.tsx @@ -0,0 +1,12 @@ +import { Subtle } from "./shared"; + +export const UnknownArtifact = ({ kind, data }: { kind: string; data: unknown }) => ( +
+ + No viewer for {kind} yet; showing the raw data. + +
+      {JSON.stringify(data, null, 2)}
+    
+
+); diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabCategoryBudget.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabCategoryBudget.tsx new file mode 100644 index 0000000..c27b73c --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabCategoryBudget.tsx @@ -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) => { + const groups = (data.groups ?? []).filter((g) => g.categories?.length); + if (groups.length === 0) return No categories for this month.; + + return ( +
+

{formatMonth(data.month)}

+
+ + + + + + + + + + {groups.map((group) => ( + + + + + + + + {group.categories.map((c) => { + const overspent = c.available < 0; + return ( + + + + + + + ); + })} + + ))} +
CategoryAssignedActivityAvailable
{group.name}{formatMoney(sum(group.categories, "assigned"))}{formatMoney(sum(group.categories, "activity"), { signed: true })} + {formatMoney(sum(group.categories, "available"))} +
+
+ {c.name} +
+
+
+
+
{formatMoney(c.assigned)} + {formatMoney(c.activity, { signed: true })} + + {formatMoney(c.available)} +
+
+

Bar: available as a share of assigned. Red rows are overspent.

+
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx new file mode 100644 index 0000000..39db77c --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx @@ -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 ( +
+
+ {ordered.map((r) => ( +
+ + {formatMoney(r.amount).replace(/\.\d\d$/, "")} + +
+
+ ))} +
+
+ {ordered.map((r) => ( + + {label(r.key)} + + ))} +
+
+ ); +}; + +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 ( +
    + {ordered.map((r) => ( +
  • +
    + {r.key} + + {formatMoney(r.amount)} + +
    +
    +
    +
    +
    + {r.count != null && ( + + {r.count} txn{r.count === 1 ? "" : "s"} + + )} +
    +
  • + ))} +
+ ); +}; + +export const YnabSpendingReport = ({ data }: RendererProps) => { + const rows = data.rows ?? []; + return ( +
+
+
+ {formatRange(data.start_date, data.end_date)} + Grouped by {data.group_by?.replace(/_/g, " ")} +
+
+
Total
+
{formatMoney(data.total)}
+
+
+ {rows.length === 0 ? ( + No spending in this period. + ) : data.group_by === "month" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx new file mode 100644 index 0000000..07bce9a --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx @@ -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) => { + const overspent = data.overspent ?? []; + return ( +
+

{formatMonth(data.month)}

+
+ + + + {/* Positive for spending, like the spending report. */} + + + {data.age_of_money != null && ( + + )} +
+
+ Overspent ({overspent.length}) + {overspent.length === 0 ? ( + Nothing overspent this month. + ) : ( +
    + {overspent.map((c) => ( +
  • + {c.name} + + {formatMoney(c.available)} + +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabTransactions.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabTransactions.tsx new file mode 100644 index 0000000..1c418c4 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabTransactions.tsx @@ -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) => { + const [sort, setSort] = useState({ 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 No matching transactions.; + + return ( +
+ + {formatRange(data.start_date, data.end_date)} · {data.count} transaction + {data.count === 1 ? "" : "s"} + {data.count > transactions.length && ` (showing ${transactions.length})`} + +
+ + + + {COLUMNS.map((col) => { + const activeCol = sort.key === col.key; + const Icon = !activeCol ? ArrowUpDown : sort.dir === "asc" ? ArrowUp : ArrowDown; + return ( + + ); + })} + + + + {sorted.map((t, i) => ( + + + + + + + ))} + + + + + + + +
+ +
+ {formatDate(t.date, { month: "short", day: "numeric" })} + +
{t.payee}
+ {t.memo &&
{t.memo}
} +
{t.category} + {formatMoney(t.amount, { signed: true })} +
+ Total + + {formatMoney(data.total, { signed: true })} +
+
+
+ ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/format.ts b/raggr-frontend/src/components/artifacts/renderers/format.ts new file mode 100644 index 0000000..d276391 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/format.ts @@ -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)}` : ""; +} diff --git a/raggr-frontend/src/components/artifacts/renderers/index.tsx b/raggr-frontend/src/components/artifacts/renderers/index.tsx new file mode 100644 index 0000000..eab1d51 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/index.tsx @@ -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>> = { + "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 = { + "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 ; + return this.props.children; + } +} + +export const ArtifactRenderer = ({ artifact }: { artifact: Artifact }) => { + const Renderer = RENDERERS[artifact.kind]; + if (!Renderer) return ; + return ( + + + + ); +}; diff --git a/raggr-frontend/src/components/artifacts/renderers/shared.tsx b/raggr-frontend/src/components/artifacts/renderers/shared.tsx new file mode 100644 index 0000000..09c8364 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/shared.tsx @@ -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 }) => ( +

+ {children} +

+); + +export const Subtle = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +

{children}

+); + +export const Chip = ({ + icon, + children, + tone = "sand", +}: { + icon?: React.ReactNode; + children: React.ReactNode; + tone?: "sand" | "leaf" | "amber"; +}) => ( + + {icon} + {children} + +); + +export const StatTile = ({ + label, + value, + tone = "default", +}: { + label: string; + value: React.ReactNode; + tone?: "default" | "good" | "bad"; +}) => ( +
+
{label}
+
+ {value} +
+
+); + +export const OpenExternal = ({ href, label }: { href: string; label: string }) => ( + + + {label} + +); + +export const EmptyNote = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); diff --git a/raggr-frontend/src/components/artifacts/renderers/types.ts b/raggr-frontend/src/components/artifacts/renderers/types.ts new file mode 100644 index 0000000..fd7413e --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/types.ts @@ -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; +}; + +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 = { data: T; sourceRef: SourceRef | null }; diff --git a/raggr-frontend/src/components/artifacts/useArtifactPanel.ts b/raggr-frontend/src/components/artifacts/useArtifactPanel.ts new file mode 100644 index 0000000..1c43561 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/useArtifactPanel.ts @@ -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([]); + const [activeId, setActiveId] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [view, setView] = useState("tab"); + const [cache, setCache] = useState>({}); + + 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; diff --git a/raggr-frontend/src/hooks/useChat.ts b/raggr-frontend/src/hooks/useChat.ts index 88c81a3..4d1257b 100644 --- a/raggr-frontend/src/hooks/useChat.ts +++ b/raggr-frontend/src/hooks/useChat.ts @@ -45,6 +45,8 @@ type UseChatOptions = { refreshConversations: () => Promise; 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(null); const stalledQueryRef = useRef(null); + const onArtifactRef = useRef(onArtifact); + onArtifactRef.current = onArtifact; const thinkingStartRef = useRef(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 diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/mealie.meal_plan.json b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.meal_plan.json new file mode 100644 index 0000000..e6616bf --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.meal_plan.json @@ -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" + } + ] + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/mealie.recipe.json b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.recipe.json new file mode 100644 index 0000000..baf037f --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.recipe.json @@ -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" + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/mealie.shopping_lists.json b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.shopping_lists.json new file mode 100644 index 0000000..4f9cbe0 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.shopping_lists.json @@ -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": [] + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/obsidian.note.json b/raggr-frontend/test-fixtures/cowork/artifacts/obsidian.note.json new file mode 100644 index 0000000..959234a --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/obsidian.note.json @@ -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" + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/search.documents.json b/raggr-frontend/test-fixtures/cowork/artifacts/search.documents.json new file mode 100644 index 0000000..2c3a4ed --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/search.documents.json @@ -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" + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/ynab.category_budget.json b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.category_budget.json new file mode 100644 index 0000000..fad40cb --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.category_budget.json @@ -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 + } + ] + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/ynab.spending_report.json b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.spending_report.json new file mode 100644 index 0000000..72cb53d --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.spending_report.json @@ -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 + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/ynab.summary.json b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.summary.json new file mode 100644 index 0000000..fa7fc5d --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.summary.json @@ -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 + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/artifacts/ynab.transactions.json b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.transactions.json new file mode 100644 index 0000000..5a24b86 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.transactions.json @@ -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 + } + ] + } +} diff --git a/raggr-frontend/test-fixtures/cowork/mock-server.mjs b/raggr-frontend/test-fixtures/cowork/mock-server.mjs index e7f98e5..4e1fff6 100644 --- a/raggr-frontend/test-fixtures/cowork/mock-server.mjs +++ b/raggr-frontend/test-fixtures/cowork/mock-server.mjs @@ -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/ 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/.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/ 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/.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}` });