@tetralabs/loom-react-ui
v0.1.0
Published
React chat controllers and dashboard components for Loom
Readme
@tetralabs/loom-react-ui
Pre-release: This package is under active development, and its API may change before the first stable release.
React composition primitives for Loom chat and dashboards.
The package has two independent entry points:
/chat: Chat controllers, composable views, and a styled default renderer./dashboard: Dashboard controllers, binding utilities, layouts, chart and content widgets, and a complete renderer.
Consumers own routing, notifications, dialogs, and application-specific widgets.
Start here
| Goal | Use | Import |
| --------------------------------------- | ------------------------------------------- | ----------------------------------- |
| Complete chat with default UI | LoomChatRoot | @tetralabs/loom-react-ui/chat |
| Replace transcript or composer UI | Focused component render props | @tetralabs/loom-react-ui/chat |
| Build a fully custom chat UI | useLoomConversation inside LoomChatRoot | @tetralabs/loom-react-ui/chat |
| List or manage chat sessions | Session, sharing, and retrieval hooks | @tetralabs/loom-react-ui/chat |
| Render a saved dashboard | LoomDashboard | @tetralabs/loom-react-ui/dashboard |
| Insert UI between loading and rendering | LoomDashboardRoot + LoomDashboardBoard | @tetralabs/loom-react-ui/dashboard |
| Build a custom dashboard layout | LoomDashboardView or useLoomDashboard | @tetralabs/loom-react-ui/dashboard |
| Render prepared descriptors | Screenboard or Timeboard | @tetralabs/loom-react-ui/dashboard |
The package intentionally has no top-level import. Import from /chat or
/dashboard so applications pay only for the surface they use.
Package boundaries
@tetralabs/loomowns API types, the runtime-neutral client, serialized dashboard definitions, and errors.@tetralabs/loom-reactowns query hooks and low-level React controllers.@tetralabs/loom-react-uiowns presentation-ready controllers, composition boundaries, layouts, and default widgets.- The consuming application owns routing, persistence workflows, notifications, dialogs, authorization decisions, and product-specific UI.
Installation
Install the package with the peers needed by the entry points you consume.
npm install @tetralabs/loom @tetralabs/loom-react @tetralabs/loom-react-ui @tanstack/react-query react react-domThe package is ESM-only and supports React 18 or newer and TanStack Query v5.
For the dashboard entry, also install its widget and layout peers:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @tanstack/markdown lucide-react rechartsSetup
Create one Loom client and render both required providers above components that
use this package. getHeaders runs before every request, so it can refresh an
expiring access token.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createLoomClient } from "@tetralabs/loom";
import { ClientProvider } from "@tetralabs/loom-react";
import type { ReactNode } from "react";
// Create clients once so they remain stable across renders.
const queryClient = new QueryClient();
const loomClient = createLoomClient({
baseUrl: "https://loom.example.com",
// Resolve headers per request to support refreshed access tokens.
getHeaders: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
}),
});
export function AppProviders({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ClientProvider client={loomClient}>{children}</ClientProvider>
</QueryClientProvider>
);
}Chat
LoomChatRoot creates the underlying chat controller. It accepts the same
options as ChatProvider from @tetralabs/loom-react/chat. Import the optional
stylesheet and render LoomChatRoot without children for the complete default
interface.
import { LoomChatRoot } from "@tetralabs/loom-react-ui/chat";
import "@tetralabs/loom-react-ui/chat.css";
export function Chat() {
return (
<LoomChatRoot
options={{
// Let the controller create and retain the active session ID.
session: { mode: "uncontrolled", initialId: null },
capabilities: {
activity: "replay",
approvals: { autoResume: true },
cancellation: true,
},
onError: reportError,
}}
/>
);
}The stylesheet uses package-prefixed selectors and CSS custom properties. It is optional: omit it when supplying all presentation through render props.
Separate messages and composer
LoomChatMessages and LoomChatComposer consume the same root controller and
can be placed or replaced independently. With no children, each renders its
built-in interface.
import {
LoomChatComposer,
LoomChatMessages,
LoomChatRoot,
} from "@tetralabs/loom-react-ui/chat";
<LoomChatRoot options={options}>
<main>
<LoomChatMessages />
</main>
<footer>
<LoomChatComposer placeholder="Ask about this dashboard..." />
</footer>
</LoomChatRoot>;Composer slots
Use leading and toolbar to augment the built-in composer. Slots accept a
node or a function receiving the focused composer controller. actions
replaces the default Send and Stop controls; children still replaces the
entire component.
<LoomChatComposer
leading={<SelectedSources />}
toolbar={(composer) => <span>{composer.value.length} characters</span>}
actions={(composer) => (
<MySendButton
disabled={!composer.canSubmit}
onClick={() => void composer.submit()}
/>
)}
/>Both components accept an optional focused render prop. This allows one half to remain built in while the other uses application-specific UI.
<LoomChatRoot options={options}>
<LoomChatMessages>
{(messages) => <MessageTimeline items={messages.items} />}
</LoomChatMessages>
<LoomChatComposer />
</LoomChatRoot>Advanced composition
Use useLoomConversation when one component needs the complete conversation
controller rather than one of the focused render props.
import {
LoomChatRoot,
useLoomConversation,
} from "@tetralabs/loom-react-ui/chat";
function CustomConversation() {
const chat = useLoomConversation();
return (
<CustomChat
items={chat.items}
status={chat.status}
error={chat.error}
onSend={chat.send}
onStop={chat.cancel}
/>
);
}
<LoomChatRoot options={options}>
<CustomConversation />
</LoomChatRoot>;Conversation status is one of welcome, loading, error, empty, or
ready.
Use a controlled session when routing owns the active conversation:
<LoomChatRoot
options={{
session: {
mode: "controlled",
id: sessionId,
onChange: (nextId) => navigateToSession(nextId),
},
}}
>
{children}
</LoomChatRoot>See the @tetralabs/loom-react documentation for the complete chat controller
contract.
Chat hooks
All hooks require the QueryClientProvider and ClientProvider shown in Setup.
Only useLoomConversation additionally requires LoomChatRoot.
| API | Use it for | Important contract |
| ----------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| useLoomConversation() | A custom component owning the complete chat UI | Combines messages, activity, approvals, actions, and presentation status. |
| useLoomChatSessionsController(options) | Active/archived session lists | status is local query state; filter runs client-side; activeSessionId marks but does not select a root session. |
| useLoomChatSessionActions(sessionId) | Rename, pin, archive, or update | All actions share one mutation's pending/error state; reset() clears that error. |
| useLoomChatSharing(sessionId, enabled) | Load and replace sharing policy | Load and save errors are separate. save() rejects without a session ID. |
| useLoomRetrievalScope(options) | Build source/entity restrictions | Source loading is opt-in. Empty selection produces undefined, preserving server defaults. |
| buildLoomRetrievalScope(entityTypes, sources) | Convert selections without React state | Deduplicates selected source entity IDs. |
import { useLoomChatSessionsController } from "@tetralabs/loom-react-ui/chat";
function SessionList({ activeId }: { activeId?: string }) {
const sessions = useLoomChatSessionsController({ activeSessionId: activeId });
if (sessions.isPending) return <p>Loading...</p>;
if (sessions.error) {
return <button onClick={() => void sessions.retry()}>Retry</button>;
}
return sessions.items.map(({ session, isActive }) => (
<button key={session.id} aria-current={isActive || undefined}>
{session.title ?? "Untitled"}
</button>
));
}Dashboards
LoomDashboard loads a saved definition, runs its enabled queries, and renders
the configured screenboard or timeboard. Registries are supplied once.
import {
LoomDashboard,
type LoomDashboardRegistries,
} from "@tetralabs/loom-react-ui/dashboard";
const registries: LoomDashboardRegistries = {
valueFormatters: {
currency: (value) => `$${Number(value).toFixed(2)}`,
},
};
export function Dashboard({ dashboardKey }: { dashboardKey: string }) {
return <LoomDashboard dashboardKey={dashboardKey} registries={registries} />;
}Set enabled={false} to suppress both definition and widget-query requests.
Choose a dashboard layer
| Requirement | Preferred API |
| -------------------------------------------- | ------------------------------------------ |
| Render a saved definition unchanged | LoomDashboard |
| Add toolbar, status, or surrounding content | LoomDashboardRoot + LoomDashboardBoard |
| Replace one serialized widget type | registries.widgetRenderers |
| Resolve data normally but replace the layout | LoomDashboardView or useLoomDashboard |
| Render local widget descriptors | Screenboard or Timeboard |
| Resolve bindings outside the controller | resolveLoomDashboardBindings |
Screenboard or Timeboard
| Layout | Choose it when | Editing contract |
| ------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Screenboard | Uniform widgets where order matters more than exact placement | editMode enables drag reorder and calls onReorder. |
| Timeboard | Mixed-size widgets needing explicit placement | Uses one-based coordinates. editMode enables drag/resize, honors minimum spans, resolves collisions downward, and calls onChange. |
Both layouts expose data-widget-id and data-widget-type on shells. Pass a
string or descriptor callback to itemClassName and itemStyle. The package
does not persist edit results; save callback output in the application.
Custom widgets and overrides
Register a renderer under the serialized widget type. The same mechanism adds
application-specific widget types and replaces built-in widgets such as stat
or bar. Renderers receive the widget config, resolved props, query state, and
retry action.
import {
LoomDashboard,
type LoomDashboardWidgetRendererProps,
} from "@tetralabs/loom-react-ui/dashboard";
function SalesPipeline({ props, isPending }: LoomDashboardWidgetRendererProps) {
if (isPending) return <p>Loading...</p>;
return <SalesChart data={props.data} />;
}
function BrandedStat({ props }: LoomDashboardWidgetRendererProps) {
return <MetricCard label={props.label} value={props.value} />;
}
<LoomDashboard
dashboardKey="sales"
registries={{
widgetRenderers: {
salesPipeline: SalesPipeline,
stat: BrandedStat,
},
}}
/>;An exact widgetRenderers match takes precedence over a built-in renderer.
The existing type: "custom" plus componentKey and customWidgets registry
remain supported for compatibility.
Dashboard styling
Use board props to style the board and widget shells. Every shell also exposes
data-widget-id and data-widget-type attributes for stable CSS selectors.
<LoomDashboard
dashboardKey="sales"
timeboardProps={{
className: "sales-dashboard",
itemClassName: (widget) =>
`widget-shell widget-${widget.widgetType ?? widget.type}`,
itemStyle: (widget) =>
widget.id === "revenue" ? { border: "2px solid green" } : undefined,
}}
/>Use a renderer override when styling requires changing a widget's internal markup. Shell hooks are intended for spacing, borders, backgrounds, and layout integration.
Dashboard bindings
Widget props can contain declarative bindings that are resolved before the renderer receives them:
{ $query: "total", default: 0 }returns one query result.{ $sum: ["open", "closed"] }sums numeric query results.{ $reindex: { source: "counts", order: ["A", "B"] } }orders categorical points.{ $table: { source: "rows", rowFormatter: "contact" } }optionally maps table rows.{ $template: "{{count}} total", vars: { count: { $query: "total" } } }interpolates values.{ $format: { source: "revenue", formatter: "currency" } }applies a registered formatter.
Use resolveLoomDashboardBindings for custom composition and
collectLoomDashboardQueryKeys when a renderer needs to inspect a binding's
query dependencies. Formatter names come from the registries passed to
LoomDashboardRoot or directly to the resolver.
Registry reference
| Registry | Key | Value | Use |
| ----------------- | ------------------------- | -------------------------------------------- | --------------------------------------------------------------------- |
| widgetRenderers | Serialized widget type | LoomDashboardWidgetRendererProps component | Add a type or replace a built-in renderer. Preferred extension point. |
| customWidgets | Serialized componentKey | Component receiving resolved props | Compatibility path for type: "custom". |
| icons | Serialized icon name | Component receiving size | Resolve a widget definition's named icon. |
| valueFormatters | Formatter name | (value) => unknown | Resolve $format and string-valued valueFormatter props. |
| rowFormatters | Formatter name | (row) => unknown | Transform rows referenced by $table. |
Built-in widget catalog
| Type | Component | Primary input | Notable behavior |
| --------- | ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------ |
| area | TimeseriesWidget | Records with label and declared series keys | Line/area/bar series, dual axes, markers, events, legends, point links, peak labels. |
| bar | BarWidget | { name, ...numericSeries }[] | Vertical/horizontal, flat/stacked/relative modes, labels, drill-down click. |
| change | ChangeWidget | { label, current, previous }[] | Delta bars, invertible direction, optional interactive sorting. |
| donut | DonutWidget | { name, value }[] plus optional rings | Focused sectors, total, legend modes, links, fullscreen. |
| funnel | FunnelWidget | Ordered { name, value }[] stages | Step and end-to-end conversion visualization. |
| gauge | GaugeWidget | Percentage value from 0 to 100 | Default threshold color, optional pulse and stats. |
| iframe | IframeWidget | External src | Lazy sandboxed iframe with restrictive defaults. |
| image | ImageWidget | Image or video src | Fit, alignment, background, optional navigation URL. |
| note | NoteWidget | Markdown content | Variants, alignment, $name.value interpolation, decorative tick. |
| scatter | ScatterWidget | { x, y, z?, name? }[] or named series | Bubble size, drag zoom, outlier labels, density contours, point click. |
| stat | QueryValue | One string or numeric value | Precision/scaling, trend, thresholds, icon, sparkline. |
| table | TableWidget | Row records and column definitions | Search, sorting, limits, bars, aliases, conditional formatting, row click. |
| text | TextWidget | Plain text | Size, alignment, foreground and named background colors. |
| toplist | TopListWidget | Ranked { label, value }[] | Flat/stacked bars, scaling, sorting, conditional colors, links. |
| treemap | TreemapWidget | Flat leaves or one level of groups | Proportional cells, drill-down, links, fullscreen. |
Widgets accept presentation-ready data and do not issue domain queries. Most
framed widgets accept isLoading and error; LoomDashboardBoard supplies
those from widget query state unless resolved props already define them.
Related packages
@tetralabs/loomprovides the runtime-neutral Loom API client.@tetralabs/loom-reactprovides the providers, hooks, and controllers composed by this package.
License
MIT. See LICENSE.
