From 396e24f2f0216e77586424d1c49640f3b24b5b61 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 12 Oct 2026 18:02:53 -0400 Subject: [PATCH 1/4] Add artifact API service, per-kind fixtures and Cowork mock server artifactTypes.ts mirrors the contract (worker C creates the same file). artifactService.ts wraps GET /api/artifacts and /api/artifacts/ with the shared fetchWithAuth helper. test-fixtures/cowork/artifacts/.json holds one ToolArtifact per v1 kind. mock-server.mjs is a dependency-free Node server that serves dist/ and speaks the contract (SSE v2 tool events, artifacts API) for Playwright checks without Docker or OIDC. Co-Authored-By: Claude Opus 5.5 --- raggr-frontend/src/api/artifactService.ts | 36 ++++++ .../cowork/artifacts/mealie.meal_plan.json | 97 ++++++++++++++++ .../cowork/artifacts/mealie.recipe.json | 57 +++++++++ .../artifacts/mealie.shopping_lists.json | 70 ++++++++++++ .../cowork/artifacts/obsidian.note.json | 24 ++++ .../cowork/artifacts/search.documents.json | 34 ++++++ .../artifacts/ynab.category_budget.json | 108 ++++++++++++++++++ .../artifacts/ynab.spending_report.json | 55 +++++++++ .../cowork/artifacts/ynab.summary.json | 30 +++++ .../cowork/artifacts/ynab.transactions.json | 87 ++++++++++++++ 10 files changed, 598 insertions(+) create mode 100644 raggr-frontend/src/api/artifactService.ts create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/mealie.meal_plan.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/mealie.recipe.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/mealie.shopping_lists.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/obsidian.note.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/search.documents.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/ynab.category_budget.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/ynab.spending_report.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/ynab.summary.json create mode 100644 raggr-frontend/test-fixtures/cowork/artifacts/ynab.transactions.json 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/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..81fe5f9 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.meal_plan.json @@ -0,0 +1,97 @@ +{ + "kind": "mealie.meal_plan", + "title": "Meal plan, Oct 12–18", + "source_ref": { + "system": "mealie" + }, + "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-15", + "meals": [] + }, + { + "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..c72e3ba --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.recipe.json @@ -0,0 +1,57 @@ +{ + "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" + ], + "image_url": "/mock-assets/tikka.svg" + } +} 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..0c6d89a --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/mealie.shopping_lists.json @@ -0,0 +1,70 @@ +{ + "kind": "mealie.shopping_lists", + "title": "Shopping lists", + "source_ref": { + "system": "mealie" + }, + "data": { + "lists": [ + { + "name": "Groceries", + "items": [ + { + "name": "Chicken thighs", + "quantity": "1.5 lb", + "checked": false + }, + { + "name": "Whole-milk yogurt", + "quantity": 1, + "checked": true + }, + { + "name": "Crushed tomatoes (28 oz)", + "quantity": 1, + "checked": false + }, + { + "name": "Heavy cream", + "quantity": 1, + "checked": false + }, + { + "name": "Cilantro", + "quantity": null, + "checked": true + }, + { + "name": "Tortillas", + "quantity": "2 packs", + "checked": false + } + ] + }, + { + "name": "Costco", + "items": [ + { + "name": "Cat litter", + "quantity": 2, + "checked": false + }, + { + "name": "Paper towels", + "quantity": 1, + "checked": true + }, + { + "name": "Olive oil", + "quantity": 1, + "checked": false + } + ] + }, + { + "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..9087754 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/obsidian.note.json @@ -0,0 +1,24 @@ +{ + "kind": "obsidian.note", + "title": "Simba vet visit 2026-09", + "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": "Simba vet visit 2026-09", + "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..ce88862 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/search.documents.json @@ -0,0 +1,34 @@ +{ + "kind": "search.documents", + "title": "Search: Simba vaccination records", + "source_ref": { + "system": "paperless" + }, + "data": { + "query": "Simba vaccination records", + "source": "paperless", + "results": [ + { + "title": "Mission Pet Hospital invoice — Sep 2026", + "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 vaccination certificate", + "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": "Adoption packet — SF SPCA", + "snippet": "Simba, approx. 10 weeks. Vaccines given: FVRCP #1 (2022-05-02), deworming. Microchip 985112004567213 registered to adopter.", + "path": "Simba/Adoption/SPCA packet.md", + "document_type": "Note", + "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..1567cc6 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.category_budget.json @@ -0,0 +1,108 @@ +{ + "kind": "ynab.category_budget", + "title": "October 2026 category budget", + "source_ref": { + "system": "ynab" + }, + "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..876c838 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.spending_report.json @@ -0,0 +1,55 @@ +{ + "kind": "ynab.spending_report", + "title": "Spending by category, Sep 2026", + "source_ref": { + "system": "ynab" + }, + "data": { + "start_date": "2026-09-01", + "end_date": "2026-09-30", + "group_by": "category", + "total": -3684.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": "Electric", + "amount": -97.55, + "count": 1 + }, + { + "key": "Pet Care", + "amount": -148.0, + "count": 2 + }, + { + "key": "Transportation", + "amount": -74.32, + "count": 5 + }, + { + "key": "Internet", + "amount": -70.0, + "count": 1 + }, + { + "key": "Pet Food", + "amount": -62.5, + "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..1e45889 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.summary.json @@ -0,0 +1,30 @@ +{ + "kind": "ynab.summary", + "title": "October 2026 budget summary", + "source_ref": { + "system": "ynab" + }, + "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..5b452dc --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/artifacts/ynab.transactions.json @@ -0,0 +1,87 @@ +{ + "kind": "ynab.transactions", + "title": "Dining out, last 30 days", + "source_ref": { + "system": "ynab" + }, + "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", + "memo": null, + "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", + "memo": null, + "amount": -6.25 + }, + { + "date": "2026-10-03", + "payee": "Souvla", + "category": "Dining Out", + "account": "Chase Sapphire", + "memo": null, + "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", + "memo": null, + "amount": -6.25 + }, + { + "date": "2026-09-18", + "payee": "Golden Boy Pizza", + "category": "Dining Out", + "account": "Chase Sapphire", + "memo": null, + "amount": -29.6 + }, + { + "date": "2026-09-14", + "payee": "Tartine Bakery", + "category": "Dining Out", + "account": "Chase Sapphire", + "memo": null, + "amount": -12.75 + } + ] + } +} -- 2.52.0 From 0ae87ee610419025672952e685a055d992443483 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 12 Oct 2026 18:02:54 -0400 Subject: [PATCH 2/4] Add artifact panel with per-kind renderers Right-hand pane (440px, lg and up) with tabs, collapse rail and a library of the conversation's artifacts; a bottom sheet below lg. useArtifactPanel holds tabs/active and caches fetched artifacts by id; tabs reset on conversation change. Artifacts from tool_end auto-open on windows >= 1280px (newest wins, and a panel the user collapsed stays collapsed). ChatScreen exposes openArtifact for the activity timeline's onOpenArtifact and adds an Artifacts button to the chat header. One renderer per v1 kind (YNAB summary, category budget, spending report, transactions; Mealie recipe, meal plan, shopping lists; Obsidian note; document search) using CSS bars only, plus a JSON fallback for unknown kinds or renderer errors. Co-Authored-By: Claude Opus 5.5 --- raggr-frontend/src/App.css | 8 + raggr-frontend/src/components/ChatScreen.tsx | 90 +++- .../components/artifacts/ArtifactPanel.tsx | 384 ++++++++++++++++++ .../artifacts/renderers/MealieMealPlan.tsx | 63 +++ .../artifacts/renderers/MealieRecipe.tsx | 116 ++++++ .../renderers/MealieShoppingLists.tsx | 50 +++ .../artifacts/renderers/ObsidianNote.tsx | 30 ++ .../artifacts/renderers/SearchDocuments.tsx | 48 +++ .../artifacts/renderers/UnknownArtifact.tsx | 12 + .../renderers/YnabCategoryBudget.tsx | 83 ++++ .../renderers/YnabSpendingReport.tsx | 103 +++++ .../artifacts/renderers/YnabSummary.tsx | 47 +++ .../artifacts/renderers/YnabTransactions.tsx | 118 ++++++ .../components/artifacts/renderers/format.ts | 50 +++ .../components/artifacts/renderers/index.tsx | 84 ++++ .../components/artifacts/renderers/shared.tsx | 77 ++++ .../components/artifacts/renderers/types.ts | 107 +++++ .../components/artifacts/useArtifactPanel.ts | 118 ++++++ raggr-frontend/src/hooks/useChat.ts | 6 + 19 files changed, 1581 insertions(+), 13 deletions(-) create mode 100644 raggr-frontend/src/components/artifacts/ArtifactPanel.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/MealieMealPlan.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/MealieRecipe.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/ObsidianNote.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/SearchDocuments.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/UnknownArtifact.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/YnabCategoryBudget.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/YnabTransactions.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/format.ts create mode 100644 raggr-frontend/src/components/artifacts/renderers/index.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/shared.tsx create mode 100644 raggr-frontend/src/components/artifacts/renderers/types.ts create mode 100644 raggr-frontend/src/components/artifacts/useArtifactPanel.ts 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/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..466e615 --- /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..59bd088 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx @@ -0,0 +1,50 @@ +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} + + {item.quantity != null && item.quantity !== "" && item.quantity !== 0 && ( + {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..0b33bc9 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx @@ -0,0 +1,103 @@ +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) => Math.abs(r.amount)), 1); + return ( +
+
+ {ordered.map((r) => ( +
+ + {formatMoney(Math.abs(r.amount)).replace(/\.\d\d$/, "")} + +
+
+ ))} +
+
+ {ordered.map((r) => ( + + {label(r.key)} + + ))} +
+
+ ); +}; + +const HorizontalBars = ({ rows }: { rows: Row[] }) => { + const ordered = [...rows].sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)); + const max = Math.max(...ordered.map((r) => Math.abs(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..b6ad5ff --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx @@ -0,0 +1,47 @@ +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)}

+
+ + + + + + {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..a7ceaaf --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/format.ts @@ -0,0 +1,50 @@ +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, end?: string): string { + if (start && end) return `${formatDate(start)} – ${formatDate(end)}`; + return formatDate(start ?? end); +} 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..1c7c9c9 --- /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, SourceRef } 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..e766658 --- /dev/null +++ b/raggr-frontend/src/components/artifacts/renderers/types.ts @@ -0,0 +1,107 @@ +// Per-kind `data` shapes (docs/cowork-ui-plan.md § V1 kinds). Renderers treat +// them as best-effort: optional chaining guards against partial payloads. + +export type SourceRef = { + system: "mealie" | "obsidian" | "paperless" | "ynab" | "web"; + id?: string; + path?: string; + url?: string; +}; + +export type MealieRecipeData = { + slug: string; + name: string; + description: string; + 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; +}; + +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: string | number | 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; + spent: number; + age_of_money?: number | null; + net_worth: number; + overspent: { name: string; available: number }[]; +}; + +export type YnabTransactionsData = { + start_date: string; + end_date: string; + count: number; + total: number; + transactions: { + date: string; + payee: string; + category: string; + account: string; + memo?: string | null; + amount: number; + }[]; +}; + +export type YnabSpendingReportData = { + start_date: string; + end_date: string; + group_by: string; + rows: { key: string; amount: number; count?: number }[]; + total: number; +}; + +export type YnabCategoryBudgetData = { + month: string; + groups: { + name: string; + categories: { + name: string; + assigned: number; + 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 -- 2.52.0 From d065b70667c5c60b013cb3b095d7dc523a38ef35 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 12 Oct 2026 18:34:59 -0400 Subject: [PATCH 3/4] Reconcile artifact renderers and fixtures with the backend shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers A/B are merged, so match what blueprints/ynab/tools.py and conversation/agents.py actually emit: - ynab.spending_report rows and ynab.summary.spent are positive for spending; bars use the positive amount and a net refund (negative row) sorts last with no bar. - ynab.transactions start_date may be null (open-ended search); the header reads "Through Oct 12, 2026". Amounts stay signed with outflows as −$, and total is the net. - Recipe image_url is never sent, search.documents has no source_ref, memo/age_of_money/goal_target are optional; fixtures now match (backend titles, ordering, float quantities, no empty fields). - Mock server: drop the placeholder recipe image, positive month spending, and a "philz" open-ended transactions scenario. Co-Authored-By: Claude Opus 5.5 --- .../renderers/MealieShoppingLists.tsx | 3 +- .../renderers/YnabSpendingReport.tsx | 15 ++++--- .../artifacts/renderers/YnabSummary.tsx | 1 + .../components/artifacts/renderers/format.ts | 5 ++- .../components/artifacts/renderers/types.ts | 16 ++++--- .../cowork/artifacts/mealie.meal_plan.json | 9 +--- .../cowork/artifacts/mealie.recipe.json | 3 +- .../artifacts/mealie.shopping_lists.json | 45 +++++++++---------- .../cowork/artifacts/obsidian.note.json | 4 +- .../cowork/artifacts/search.documents.json | 13 +++--- .../artifacts/ynab.category_budget.json | 5 +-- .../artifacts/ynab.spending_report.json | 36 ++++++++------- .../cowork/artifacts/ynab.summary.json | 7 +-- .../cowork/artifacts/ynab.transactions.json | 11 +---- 14 files changed, 78 insertions(+), 95 deletions(-) diff --git a/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx b/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx index 59bd088..17285dc 100644 --- a/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx +++ b/raggr-frontend/src/components/artifacts/renderers/MealieShoppingLists.tsx @@ -35,7 +35,8 @@ export const MealieShoppingLists = ({ data }: RendererProps {item.name} - {item.quantity != null && item.quantity !== "" && item.quantity !== 0 && ( + {/* 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/YnabSpendingReport.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx index 0b33bc9..39db77c 100644 --- a/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSpendingReport.tsx @@ -14,7 +14,7 @@ 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) => Math.abs(r.amount)), 1); + const max = Math.max(...ordered.map((r) => r.amount), 1); return (
@@ -25,11 +25,11 @@ const MonthBars = ({ rows }: { rows: Row[] }) => { title={`${monthLabel(r.key, true)}: ${formatMoney(r.amount)}`} > - {formatMoney(Math.abs(r.amount)).replace(/\.\d\d$/, "")} + {formatMoney(r.amount).replace(/\.\d\d$/, "")}
))} @@ -46,15 +46,16 @@ const MonthBars = ({ rows }: { rows: Row[] }) => { }; const HorizontalBars = ({ rows }: { rows: Row[] }) => { - const ordered = [...rows].sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)); - const max = Math.max(...ordered.map((r) => Math.abs(r.amount)), 1); + // 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)}
    @@ -62,7 +63,7 @@ const HorizontalBars = ({ rows }: { rows: Row[] }) => {
    {r.count != null && ( diff --git a/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx b/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx index b6ad5ff..07bce9a 100644 --- a/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx +++ b/raggr-frontend/src/components/artifacts/renderers/YnabSummary.tsx @@ -15,6 +15,7 @@ export const YnabSummary = ({ data }: RendererProps) => { /> + {/* Positive for spending, like the spending report. */} Date: Mon, 12 Oct 2026 18:40:31 -0400 Subject: [PATCH 4/4] Merge artifact scenarios into the shared Cowork mock server One mock server now serves both the timeline scenarios and the artifact panel's: per-kind fixtures from artifacts/.json, an "everything" conversation with one saved tool message per kind, keyword scenarios (recipe, budget, trend, philz, everything, broken, mystery, ...), a 500ing "broken" artifact, and small delays on /api/artifacts* so the panel's loading states are visible. SPENDING now uses positive amounts like the backend. Also: renderers reuse ArtifactSourceRef, and the collapsed rail's buttons say "Show " so they don't share the timeline chip's "Open <title>" accessible name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../components/artifacts/ArtifactPanel.tsx | 2 +- .../components/artifacts/renderers/index.tsx | 4 +- .../components/artifacts/renderers/types.ts | 9 +- .../test-fixtures/cowork/mock-server.mjs | 159 ++++++++++++++++-- 4 files changed, 152 insertions(+), 22 deletions(-) diff --git a/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx b/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx index 466e615..fb65e5f 100644 --- a/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx +++ b/raggr-frontend/src/components/artifacts/ArtifactPanel.tsx @@ -97,7 +97,7 @@ const CollapsedRail = ({ panel }: { panel: ArtifactPanelState }) => ( key={tab.id} onClick={() => panel.open(tab)} title={tab.title} - aria-label={`Open ${tab.title}`} + aria-label={`Show ${tab.title}`} className={cn(iconButton, panel.active?.id === tab.id && "text-leaf-dark bg-leaf-pale")} > <Icon size={15} /> diff --git a/raggr-frontend/src/components/artifacts/renderers/index.tsx b/raggr-frontend/src/components/artifacts/renderers/index.tsx index 1c7c9c9..eab1d51 100644 --- a/raggr-frontend/src/components/artifacts/renderers/index.tsx +++ b/raggr-frontend/src/components/artifacts/renderers/index.tsx @@ -23,7 +23,7 @@ import { YnabCategoryBudget } from "./YnabCategoryBudget"; import { YnabSpendingReport } from "./YnabSpendingReport"; import { YnabSummary } from "./YnabSummary"; import { YnabTransactions } from "./YnabTransactions"; -import type { RendererProps, SourceRef } from "./types"; +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>>> = { @@ -78,7 +78,7 @@ export const ArtifactRenderer = ({ artifact }: { artifact: Artifact }) => { 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 as SourceRef | null} /> + <Renderer data={artifact.data} sourceRef={artifact.source_ref} /> </RenderBoundary> ); }; diff --git a/raggr-frontend/src/components/artifacts/renderers/types.ts b/raggr-frontend/src/components/artifacts/renderers/types.ts index d5c6846..fd7413e 100644 --- a/raggr-frontend/src/components/artifacts/renderers/types.ts +++ b/raggr-frontend/src/components/artifacts/renderers/types.ts @@ -1,12 +1,9 @@ // Per-kind `data` shapes (docs/cowork-ui-plan.md § V1 kinds). Renderers treat // them as best-effort: optional chaining guards against partial payloads. -export type SourceRef = { - system: "mealie" | "obsidian" | "paperless" | "ynab" | "web"; - id?: string; - path?: string; - url?: string; -}; +import type { ArtifactSourceRef } from "../../../api/artifactTypes"; + +export type SourceRef = ArtifactSourceRef; export type MealieRecipeData = { slug: string; 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/<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}` }); -- 2.52.0