Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion COMPONENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @cofoundy/ui — Component Catalog

**Last updated:** 2026-09-29 · **Storybook:** [ui.cofoundy.dev](https://ui.cofoundy.dev) · **Total exports:** ~106 (`chat-sim` is a separate subpath, not yet in this count — see its section)
**Last updated:** 2026-10-12 · **Storybook:** [ui.cofoundy.dev](https://ui.cofoundy.dev) · **Total exports:** ~108 (`chat-sim` is a separate subpath, not yet in this count — see its section)

> **For agents:** start at the [Intent Map](#intent-map) below — maps "I need X" → component name + import + story URL. If you don't find your need there, fall through to the [Section tables](#chat) (alphabetical) or run `grep '^export' ~/cofoundy/packages/ui/src/index.ts` for the raw export list. Storybook auto-discovery: `curl -sS https://ui.cofoundy.dev/index.json` returns 598 entries (all stories, parseable JSON).

Expand All @@ -21,6 +21,7 @@ Most common needs first. Story URL pattern: `https://ui.cofoundy.dev/?path=/docs
| Custom message bubble rendering | `MessageBubble`, `MessageContent`, `MessageAvatar`, `MessageTimestamp` | Messaging | `messaging-primitives-*` |
| Compose user input (multi-modal, quick actions, toolbar) | `MessageComposer` | Messaging | `messaging-messagecomposer` |
| Inbox-style message list (Cofoundy InboxAI) | `InboxMessage`, `InboxMessageList` | Messaging | `messaging-inboxmessage` |
| Show what an AI **tool call** did (name, summary, status, folded input/output) — one or a turn's group | `ToolInvocation`, `ToolInvocationGroup` | Messaging | `messaging-toolinvocation` |
| KPI metric tile (number / duration / percentage + trend) | `StatCard` | Analytics | `analytics-statcard` |
| Inline mini trend line (fits in StatCard / DataTable) | `SparkLine` | Analytics | `analytics-sparkline` |
| Vertical bar chart (CSS-only, animated) | `BarChart` | Analytics | `analytics-barchart` |
Expand Down Expand Up @@ -157,6 +158,8 @@ Generic messaging primitives (used by InboxAI; reusable for any chat surface).
| `InboxMessage`, `InboxMessageList` | Composed message list (InboxAI). |
| `MessageComposer` | Multi-modal input (text + attachments + toolbar + quick actions). `busy` swaps Send for Stop while an automatic reply is in flight — the textarea stays live. |
| `AIBadge` | "AI" inline pill (mark AI-generated content). |
| **`ToolInvocation`** | **NEW (2026-10-12)** — Collapsible card for one tool call of an AI turn: `name`, one-line `summary`, `state` (`input-streaming` · `input-available` · `output-available` · `output-error`), `input` (flat object → key/value list, else JSON), `output` (text · JSON string pretty-printed, a truncated one stays text · object · ReactNode), `errorText`, `defaultOpen` (folded by default), `labels` (English defaults, all overridable). Reference: Vercel AI Elements «Tool». Helpers: `ToolInvocationStatus`, `ToolValue`. |
| `ToolInvocationGroup` | Folds several `ToolInvocation` of one turn behind «AI ran N actions» (`labels.title(n)` to localize); flags the error count. |

### Analytics

Expand Down
130 changes: 130 additions & 0 deletions src/__tests__/components/ToolInvocation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MotionConfig } from "framer-motion";
import { ToolInvocation, ToolInvocationGroup } from "../../index";

function renderReduced(ui: React.ReactElement) {
return render(<MotionConfig reducedMotion="always">{ui}</MotionConfig>);
}

describe("ToolInvocation", () => {
it("renders name, summary and the completed status, folded by default", () => {
renderReduced(
<ToolInvocation
name="Consultar tarifario"
summary="Consultó tarifario MSL · Ningbo"
input={{ carrier: "MSL", origin: "Ningbo" }}
output={{ rate: 399 }}
/>,
);
const button = screen.getByRole("button", { name: /Consultar tarifario/ });
expect(button).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Consultó tarifario MSL · Ningbo")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.queryByText("Input")).toBeNull();
});

it("toggles with the keyboard and shows the input as a key/value list", async () => {
const user = userEvent.setup();
renderReduced(
<ToolInvocation name="Calcular" input={{ carrier: "MSL", wm: 4.2 }} output="USD 399" />,
);
const button = screen.getByRole("button", { name: /Calcular/ });
button.focus();
await user.keyboard("{Enter}");
expect(button).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("carrier")).toBeInTheDocument();
expect(screen.getByText("4.2")).toBeInTheDocument();
expect(screen.getByText("USD 399")).toBeInTheDocument();
await user.keyboard(" ");
expect(button).toHaveAttribute("aria-expanded", "false");
});

it("pretty-prints a JSON string output and keeps a truncated one as text", () => {
const { container, rerender } = renderReduced(
<ToolInvocation name="t" defaultOpen output='{"success": true, "items": [1, 2]}' />,
);
expect(container.querySelector('[data-slot="tool-invocation-list"], pre')?.textContent).toContain(
"success",
);
rerender(
<MotionConfig reducedMotion="always">
<ToolInvocation name="t" defaultOpen output='{"success": true, "data": "abc…' />
</MotionConfig>,
);
expect(screen.getByText('{"success": true, "data": "abc…')).toBeInTheDocument();
});

it("renders nested input as JSON, not a list", () => {
const { container } = renderReduced(
<ToolInvocation name="t" defaultOpen input={{ a: { b: 1 } }} />,
);
const pre = container.querySelector('[data-slot="tool-invocation-code"]');
expect(pre?.textContent).toContain('"b": 1');
});

it("shows the error state with role=alert", () => {
renderReduced(
<ToolInvocation name="t" state="output-error" errorText="Tarifario no encontrado" defaultOpen />,
);
expect(screen.getByRole("alert")).toHaveTextContent("Tarifario no encontrado");
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
});

it("lets every label be overridden", () => {
renderReduced(
<ToolInvocation
name="t"
state="input-available"
defaultOpen
input={{ x: 1 }}
labels={{ running: "Ejecutando", input: "Entrada", output: "Salida", waiting: "Esperando…" }}
/>,
);
expect(screen.getByText("Ejecutando")).toBeInTheDocument();
expect(screen.getByText("Entrada")).toBeInTheDocument();
expect(screen.getByText("Salida")).toBeInTheDocument();
expect(screen.getByText("Esperando…")).toBeInTheDocument();
});

it("renders a ReactNode output as-is and calls onOpenChange", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
renderReduced(
<ToolInvocation name="t" onOpenChange={onOpenChange} output={<strong>rich</strong>} />,
);
await user.click(screen.getByRole("button"));
expect(onOpenChange).toHaveBeenCalledWith(true);
expect(screen.getByText("rich").tagName).toBe("STRONG");
});
});

describe("ToolInvocationGroup", () => {
it("counts its children, flags errors and is folded by default", async () => {
const user = userEvent.setup();
renderReduced(
<ToolInvocationGroup>
<ToolInvocation name="Uno" />
<ToolInvocation name="Dos" />
<ToolInvocation name="Tres" state="output-error" errorText="x" />
</ToolInvocationGroup>,
);
const header = screen.getByRole("button", { name: /AI ran 3 actions/ });
expect(header).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("1 error")).toBeInTheDocument();
expect(screen.queryByText("Uno")).toBeNull();
await user.click(header);
expect(screen.getByText("Uno")).toBeInTheDocument();
});

it("accepts a localized title function", () => {
renderReduced(
<ToolInvocationGroup labels={{ title: (n) => `La IA hizo ${n} acciones` }}>
<ToolInvocation name="Uno" />
<ToolInvocation name="Dos" />
</ToolInvocationGroup>,
);
expect(screen.getByText("La IA hizo 2 acciones")).toBeInTheDocument();
});
});
Loading
Loading