@pmcollab/coworkstream
v1.5.0
Published
Headless, props-driven Work Stream list component for React. Designed for embedding in operator/agent dashboards.
Maintainers
Readme
@pmcollab/coworkstream
A headless, props-driven Work Stream list component for React. Drop it into an operator dashboard, agent console, or inbox-style UI and wire it to your own data and events.
Coding agents: if you are a coding agent integrating this package on behalf of a developer, read
AGENTS.md. It contains a single-page integration recipe with the full prop schema, event contract, and a paste-ready example.Migrating from an existing in-house implementation? Read
MIGRATION.mdfor the adapter pattern (extracted from a real ~600-LoC migration of the CoWorkStream host app onto this package).
Highlights
- Headless and uncontrolled-by-default. Items in, events out. No global stores, no data fetching, no opinions about your backend.
- Slot-based.
renderDetails,renderActions,renderHeader,renderEmptyState,renderLoadinglet you replace any chunk of the UI. - Controllable. Both
activeFilterandexpandedIdsupport controlled and uncontrolled patterns. - Mobile-aware. Built-in responsive layout, with optional "defer to desktop" workflow.
- TypeScript-first. Hand-authored
.d.tsdeclarations are shipped alongside the source. Type-tested withtsd. - Pre-built ESM + CJS + standalone CSS. Use it without Tailwind. Use it without a JSX-aware build.
- Production-grade safety. Runtime prop validation in dev, error boundaries around render slots, axe-core accessibility tests in CI, SSR smoke-tested for Next.js / Remix.
- Tiny. ~12 kB ESM (brotli), ~3.6 kB CSS (gzip). Tree-shakable.
Install
npm install @pmcollab/coworkstream
# peer deps
npm install react react-domYou have a few ways to load the styles. Pick one:
No Tailwind in your app, Tailwind v3-shaped host CSS (most apps today):
import '@pmcollab/coworkstream/styles.css'No Tailwind in your app, but you want full isolation from any host styling:
import '@pmcollab/coworkstream/styles-scoped.css'Every selector in this bundle is prefixed with
.coworkstream-root, so the rules only match inside the component's subtree.<WorkStream>automatically adds thecoworkstream-rootclass to its<section>root, so this just works without extra wrappers.Host is on Tailwind v4 and you don't want to wire up the preset:
import '@pmcollab/coworkstream/styles-v4.css'Same utilities, compiled with
@tailwindcss/cliv4 so the variable schema (--tw-gradient-position,color-mix(in oklab …), registered@propertydeclarations) matches a v4 host.Already using Tailwind? Wire up the Tailwind preset so unused classes aren't purged and skip every
styles*.cssimport — you only need one styling path.
Cascade safety. All three prebuilt bundles wrap their rules in
@layer coworkstream { … }. Per CSS Cascade Level 5, unlayered rules beat layered rules, so if you import a bundle in an app that already emits Tailwind utilities, your app's.mb-4/.bg-amber-100/ etc. always take precedence and the bundle only fills in classes you haven't emitted. The scoped bundle adds belt-and-suspenders isolation for hosts whose own CSS uses cascade layers in a way that could otherwise change the order.
Quickstart
import { WorkStream } from '@pmcollab/coworkstream'
const items = [
{
id: 'roadmap-q2',
category: 'decision',
priority: 'high',
title: 'Roadmap conflict: Slot Q2 capacity',
subtitle: '3 agents • conflict',
summary: 'Engineering is blocked until Friday.',
icon: '✋',
iconBg: '#ef4444',
deadline: 'Friday',
},
{
id: 'beta-feedback',
category: 'signal',
title: 'Beta feedback spike: Workflow Builder permissions',
subtitle: 'Customer Insights Agent • 9 of 23 beta users flagged',
icon: '📡',
},
]
export function Inbox() {
return (
<WorkStream
items={items}
onAction={(action, item) => {
console.log('action', action, 'on', item.id)
}}
onExpandChange={(id) => console.log('expanded', id)}
/>
)
}That's the minimum. The component renders its own header, filter chips, and quick action buttons.
The data model
interface WorkStreamItem {
id: string // required, stable
category: string // 'decision' | 'signal' | 'draft' | ... or any custom string
title: string // required
subtitle?: string
summary?: string // shown in the expanded body
priority?: 'low' | 'medium' | 'high'
icon?: ReactNode // emoji string or any node
iconBg?: string // CSS color (20% alpha applied)
deadline?: string // free-form, e.g. 'Friday', 'EOD', '24 hours'
badge?: { label: string; tone?: BadgeTone; tooltip?: string } // override default badge
tooltips?: Partial<Record<string, string>> // per-action hover text (see below)
help?: string | { title?: ReactNode; html?: string; content?: ReactNode } // ? help icon + modal
deviceTarget?: 'mobile' | 'desktop' | 'any'
unbundledFrom?: string
consolidatedItems?: WorkStreamItem[]
isMultiAgent?: boolean
multiAgent?: {
alignmentStatus: 'aligned' | 'partial' | 'conflict'
tooltip?: string // hover text for the whole multi-agent badge
positions: Array<{ avatar: string; agentId?: string; agentName?: string; tooltip?: string }>
}
meta?: Record<string, unknown> // pass-through; available in render slots
}Built-in category keys (with default badge colors): decision, prepared, signal, draft, consolidated, audit, incident, hiring. Custom strings are allowed — if no built-in tone matches, the badge falls back to default styling. Use item.badge.tone to opt into a specific color.
Custom filters
const filters = [
{ key: 'all', label: 'All' },
{ key: 'urgent', label: 'Urgent', match: (item) => item.priority === 'high' },
{ key: 'mine', label: 'Mine', match: (item) => item.meta?.assignee === currentUser.id },
{ key: 'signal', label: 'Signal' }, // implicit category match
]
<WorkStream items={items} filters={filters} defaultActiveFilter="urgent" />If match is omitted, items are matched by item.category === filter.key. The chip with key: 'all' is special-cased and always shows everything.
Custom expanded content
<WorkStream
items={items}
renderDetails={(item) => (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Risks</h3>
<ul>
{(item.meta?.risks ?? []).map((r) => <li key={r.id}>{r.label}</li>)}
</ul>
</div>
)}
renderActions={(item, ctx) => (
<div className="flex gap-2">
<button onClick={() => ctx.onAction('approve')}>Approve</button>
<button onClick={() => ctx.onAction('reject')}>Reject</button>
</div>
)}
onAction={(action, item) => api.itemAction(item.id, action)}
/>renderActions receives a ctx.onAction(action) helper — call it from inside your custom buttons and the parent onAction will fire with the (action, item) pair, just like the built-in quick actions.
Controlled mode
const [filter, setFilter] = useState('all')
const [openId, setOpenId] = useState(null)
<WorkStream
items={items}
activeFilter={filter}
onFilterChange={setFilter}
expandedId={openId}
onExpandChange={setOpenId}
/>Defer-to-desktop
Opt-in workflow for "I'll handle this when I'm at a real keyboard." On mobile, items render a 🖥 button; on desktop, deferred items float to the top with a banner.
const [deferredIds, setDeferredIds] = useState([])
<WorkStream
items={items}
deferredIds={deferredIds}
onDeferToDesktop={(item) => setDeferredIds((ids) => [...ids, item.id])}
onClearDeferred={(item) => setDeferredIds((ids) => ids.filter((x) => x !== item.id))}
/>If you don't pass onDeferToDesktop, the feature is disabled and the 🖥 button is not rendered.
Hover text (tooltips)
Every action button on a card renders a native hover tooltip (the HTML title
attribute). Each one has a sensible default, and you can override any of them
per item through a single tooltips map — the consistent way to set custom
hover text across all cards:
const items = [
{
id: 'roadmap-q2',
category: 'decision',
title: 'Roadmap conflict: Slot Q2 capacity',
tooltips: {
delegate: 'Hand this to the staffing agent',
defer: 'Snooze until the next planning cycle',
ignore: 'Dismiss — this no longer needs a decision',
},
},
]Recognized keys map to the built-in buttons: delegate, defer, ignore,
deferToDesktop, viewFullDetails, and help. Any key you omit falls back to
the shared default in labels[${key}Tooltip] (overridable globally via the
labels prop for i18n). Custom keys are allowed too, so the same map can carry
hover text for buttons you render yourself in renderActions:
<WorkStream
items={items}
renderActions={(item, ctx) => (
<button title={item.tooltips?.approve} onClick={() => ctx.onAction('approve')}>
Approve
</button>
)}
/>Badge and agent-avatar tooltips
Badges accept hover text the same way:
{
id: 'x',
category: 'decision',
title: 'Approve Q2 plan',
badge: { label: 'Decision', tone: 'decision', tooltip: 'Needs a human sign-off' },
isMultiAgent: true,
multiAgent: {
alignmentStatus: 'conflict',
tooltip: '2 agents disagree on this item', // whole badge
positions: [
{ avatar: '🛰', agentName: 'Atlas' }, // hover → "Atlas"
{ avatar: '📡', agentName: 'Beacon', tooltip: 'Beacon (lead)' }, // hover → "Beacon (lead)"
],
},
}Each stacked agent avatar shows its own hover text. Set position.tooltip to
customize it; otherwise it defaults to the agent's agentName.
Help content (the ? icon)
Attach help to an item to surface a ? icon on the card. The icon only
renders when help is present. Clicking it floats a modal dialog (focus-
trapped, Esc-to-close) containing the help content:
const items = [
{
id: 'doc-review',
category: 'prepared',
title: 'Review onboarding doc',
// String shorthand = raw HTML rendered inside the modal.
help: '<p>This item needs a <strong>legal</strong> sign-off before publishing. See the <a href="/runbook">runbook</a>.</p>',
},
]For a custom title or rich React content, pass an object instead of a string:
{
id: 'approvals',
category: 'decision',
title: 'Approve budget',
help: {
title: 'How approvals work',
content: <ApprovalsHelpPanel />, // any ReactNode; takes precedence over `html`
},
}The modal title defaults to labels.helpTitle ("Help") and the icon's hover
text to labels.helpTooltip — both overridable globally via labels, or
per-item via tooltips.help.
Security:
help(orhelp.html) is rendered withdangerouslySetInnerHTML. It is author-controlled content from your item model. Sanitize it if any part originates from untrusted input.
Theming
The package uses Tailwind CSS classes and supports class-based dark mode (<html class="dark">).
1. Add the package paths to your content glob
// tailwind.config.{js,ts}
import workstreamPreset from '@pmcollab/coworkstream/tailwind-preset'
export default {
presets: [workstreamPreset],
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./node_modules/@pmcollab/coworkstream/src/**/*.{js,jsx}',
],
// ...
}This ensures Tailwind doesn't purge classes used inside the package, and adds the scrollbar-hide utility used by the filter chip row.
2. Customize via className / itemClassName
The outer <section> accepts className. Each item card accepts itemClassName (applied to every <WorkStreamItemCard>). For deeper customization, use the render slots.
3. Internationalization
Pass labels to override any user-facing string. Defaults are in English.
<WorkStream
items={items}
labels={{
title: 'Bandeja',
delegate: 'Delegar',
defer: 'Aplazar',
ignore: 'Ignorar',
allClear: 'Todo en orden',
}}
/>See the WorkStreamLabels type for the full list.
Full prop reference
See types/index.d.ts — the declarations are the source of truth for the public API. Every prop, event, and helper is documented there with JSDoc.
| Prop | Type | Notes |
|---|---|---|
| items | WorkStreamItem[] | Required. |
| filters | FilterDef[] | Default: 8 built-in chips. |
| activeFilter / defaultActiveFilter / onFilterChange | controlled trio | |
| expandedId / defaultExpandedId / onExpandChange | controlled trio | |
| isLoading | boolean | Renders skeleton via renderLoading. |
| isMobile | boolean | Override viewport detection. |
| deferredIds | string[] | Opt-in defer-to-desktop. |
| onDeferToDesktop / onClearDeferred | (item) => void | |
| showDesktopItemsOnMobile (+ default + onToggle) | controlled trio | |
| onAction | (action, item) => void | Built-ins: 'delegate' \| 'defer' \| 'ignore'. |
| onItemClick | (item) => void | Fires before expand toggles. |
| renderDetails | (item) => ReactNode | Replace expanded body. |
| renderActions | (item, ctx) => ReactNode | Replace action row. |
| renderHeader | (args) => ReactNode | Replace title + chips. |
| renderLoading / renderEmptyState | () => ReactNode | |
| labels | Partial<WorkStreamLabels> | i18n. |
| className / itemClassName | string | |
Examples
The examples/ folder contains paste-ready integration files:
basic.jsx— minimal mount with mock data.custom-actions.jsx— replacing the action row with category-specific buttons.custom-details.jsx— usingrenderDetailsto render rich expanded content.with-backend.jsx— wiringonActionto a REST API and an optimistic store.
Versioning and stability
Pre-1.0 the API may evolve based on licensee feedback. Breaking changes will be documented in CHANGELOG.md.
License
Commercial. See LICENSE.
