composite-dom-render.test.tsxfix: composite playbook renders empty due to invalid parsed content
The composite playbook editor loaded blank even though the server data
was intact. Root cause: empty markdown leaves (e.g. an empty ``` fence in
a cross-cutting block) parse to a codeBlock holding an empty text node
({type:'text',text:''}), which ProseMirror forbids — so setContent throws
"Empty text nodes are not allowed" and the whole doc fails to render.
Strip empty text nodes at the source (parsePlaybookXmlToJson) and again in
mergeComposite. Also hardens the open→render→save path so this class of
failure can't recur silently or destroy data:
- always fetch the playbook fresh (blacklist from IndexedDB persistence +
staleTime 0 / refetchOnMount 'always') so a stale/empty cached copy is
never rendered without a network call
- defer setContent to a microtask so TipTap's React NodeViews render
(React refuses flushSync inside a lifecycle)
- re-seed on fresh data without clobbering unsaved edits; surface a visible
error instead of a silent blank on hydration failure
- refuse to overwrite a populated playbook with an empty doc (server guard
in saveCompositePlaybook + client guard when unhydrated)
- getChatAgentPlaybook fetches its documentId fresh too
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 6, 2026, 9:32 PMcomposite-hydration.test.tsxfix: composite playbook renders empty due to invalid parsed content
The composite playbook editor loaded blank even though the server data
was intact. Root cause: empty markdown leaves (e.g. an empty ``` fence in
a cross-cutting block) parse to a codeBlock holding an empty text node
({type:'text',text:''}), which ProseMirror forbids — so setContent throws
"Empty text nodes are not allowed" and the whole doc fails to render.
Strip empty text nodes at the source (parsePlaybookXmlToJson) and again in
mergeComposite. Also hardens the open→render→save path so this class of
failure can't recur silently or destroy data:
- always fetch the playbook fresh (blacklist from IndexedDB persistence +
staleTime 0 / refetchOnMount 'always') so a stale/empty cached copy is
never rendered without a network call
- defer setContent to a microtask so TipTap's React NodeViews render
(React refuses flushSync inside a lifecycle)
- re-seed on fresh data without clobbering unsaved edits; surface a visible
error instead of a silent blank on hydration failure
- refuse to overwrite a populated playbook with an empty doc (server guard
in saveCompositePlaybook + client guard when unhydrated)
- getChatAgentPlaybook fetches its documentId fresh too
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 6, 2026, 9:32 PMcomposite-merge.test.tsfix: composite playbook renders empty due to invalid parsed content
The composite playbook editor loaded blank even though the server data
was intact. Root cause: empty markdown leaves (e.g. an empty ``` fence in
a cross-cutting block) parse to a codeBlock holding an empty text node
({type:'text',text:''}), which ProseMirror forbids — so setContent throws
"Empty text nodes are not allowed" and the whole doc fails to render.
Strip empty text nodes at the source (parsePlaybookXmlToJson) and again in
mergeComposite. Also hardens the open→render→save path so this class of
failure can't recur silently or destroy data:
- always fetch the playbook fresh (blacklist from IndexedDB persistence +
staleTime 0 / refetchOnMount 'always') so a stale/empty cached copy is
never rendered without a network call
- defer setContent to a microtask so TipTap's React NodeViews render
(React refuses flushSync inside a lifecycle)
- re-seed on fresh data without clobbering unsaved edits; surface a visible
error instead of a silent blank on hydration failure
- refuse to overwrite a populated playbook with an empty doc (server guard
in saveCompositePlaybook + client guard when unhydrated)
- getChatAgentPlaybook fetches its documentId fresh too
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 6, 2026, 9:32 PMplaybook-document-owner.test.tsxfix(lint): oxlint reaches zero, without a single underscore
CI runs oxlint with --deny-warnings, and this branch carried nine. Every one is
in a test, and all nine are the same two shapes.
Two unused imports and a dead type alias are deletions, which is the rule.
The other six are `vi.fn(async (...args: unknown[]) => …)` — a rest parameter
nobody reads, but not dead code either: it is what lets the mock accept the
arguments its call site spreads into it, so deleting the binding turns every
`spy(...a)` into "expected 0 arguments". The signature moves into `vi.fn`'s type
parameter instead, where it goes on saying the same thing to the compiler with
nothing left over for the linter to find. Underscoring them was the other option
and the repo bans it, for the reason this case illustrates: `_args` would have
hidden a parameter that is load-bearing behind a convention that means "ignore
me".
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:42 PMpost-api-node.test.tsxfeat(files): the agent workspace's own rows join the kit (design: unified-files-ui phase 4)
The Output tab's tree already came from the conversation tree, but its two flat
lists did not: "Touched elsewhere" drew a rounded-square tile with a mono path
under the name, and "In conversations" drew a round medallion with an emoji.
Three chromes, one tab.
Both now render `FileListRow`, so the columns line up with the tree above them.
The path keeps its place as provenance — in the meta slot, one line, truncated,
full value on hover — rather than as a second line that would make every row
twice as tall as the tree's.
Editor identity for both lists comes from ONE batched `documents.lastEditors`
over the union of their ids, the shape `files.unseenAgentEdits` established,
because the alternative is a request per row on every render.
Two fixes that were in the way:
- the shared tRPC test mock resolved queries to `undefined`, which TanStack
rejects — every component firing an unstubbed query buried its own test
output in "Query data cannot be undefined".
- the playbook slash-menu test still expected nine entries after Board, Table
and Document were added to the menu in dfc2197fe.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:57 PMtrigger-dispatch.test.tsxfix(agents): drop the building glyph, green the calendar, unlabel the instruction box
THE GLYPH ROW. `external_crm` loses its mark entirely rather than gaining a better
one. Email and Slack wear real vendor logos and a meeting is a calendar, but "the
external CRM" is whichever of HubSpot, Salesforce, Attio or Copper this user
connected — so any single glyph either names the wrong vendor or names nothing. A
building says "a company" and invites the question "which one?", to which there is
no answer; sync arrows say "refresh", which is an action rather than an event. An
honest gap beats a mark that needs explaining.
So `Icon` is now optional on TriggerEventType, and the absence is the documented
point. `external_crm` stays a first-class event everywhere it matters — the picker
offers it, the row's accessible name lists it, the `?` explainer names it in prose.
Only the compact glyph row skips it, and that row was always a summary rather than
a census. The trigger badge falls back to the same generic mark `any` wears.
The meeting calendar is green, matching what the conversation timeline gives a
meeting. No tinted discs: the two vendor logos hardcode their brand fills, so a
text colour cannot reach them, and the calendar was the one grey mark in a row of
colour — it was the only one that needed anything.
THE INSTRUCTION BOX. "Instructions" as a visible label made every source row read
as a section heading over a box; stacked six deep that is six headings for one
idea. The label is gone and the sentence under the box does the naming instead —
which it had to do anyway, because "Instructions" alone never said WHICH
instructions or how they differ from the agent's own:
"Trigger-specific instructions (optional): if you want special instructions for
only this trigger to be given to the agent."
That needed a real kit change, not a workaround: `Field` now accepts `label={null}`
and renders no label element. It is `null` rather than an omitted prop because a
label is the default and dropping one should be a decision somebody typed — and the
control still carries an `aria-label`, since "obvious from context" is only true
for the reader who can see the context.
330 agent + playbook tests green, including new ones pinning that external_crm is
named but not drawn, and that only the meeting carries a tint.
Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 11:59 AMtrigger-ref-panel.test.tsxfix(playbook): the four things wrong with the trigger UI
1. TWO GLYPHS THAT SAID NOTHING.
The meeting glyph was invisible. It asked `connectionLogo('meetings')`, which
resolves to the hand-drawn icons.tsx Calendar — an `<svg fill="none">` whose
path sets no fill of its own, so it inherits `none` and paints NOTHING unless
the call site passes a `fill-*` class. That row passes only a size. Fixed at
both ends: the glyph row now uses lucide's Calendar (which carries its own
stroke), and the shared icon gets `fill="currentColor"` on its path so every
other call site that passes only a size renders too. It is a presentation
attribute, which a CSS class beats, so date-picker-dialog's `fill-[#9D9D9D]`
is unaffected.
The external-CRM glyph was sync arrows, which read as "refresh" — an action,
not an event — and named neither the event nor the system. Both marks now come
from the conversation timeline (Calendar, Building2), which is where a user has
already learned what these events look like. The trigger badge follows suit.
2. THE MEMBER PICKER MOVES INTO THE BREADCRUMB ROW.
It answers the same question the crumbs do — WHOSE playbook is this — so on its
own row underneath it read as a filter over the content rather than as part of
the address, and cost a row of height on every load for a control most sessions
never touch. Only the picker moves: the banner and the permission notice stay in
AdministeredUserBar, because they are full-width messages and squeezing either
into a title row would truncate it or shove the crumbs sideways as it appears.
The bar now collapses to nothing when it holds neither.
3. A REF WITH NO INSTRUCTION HAD NO WAY TO GET ONE.
Every ref authored before this feature is self-closing, so it parses to the
inline chip — and the chip had nowhere to put an instruction. That is not a
migration to wait out; it is every ref in every playbook today. The chip now
carries a second half, `+ Instructions`, which converts it into the panel with
the caret already in the instruction area.
Offered ONLY inside a trigger: a ref in <always-loaded> is a document the agent
reads, not an agent a trigger fires, so there is no trigger for it to have an
instruction for. The shared FileLinkNode does not learn what a trigger is — it
asks its host "does anyone want a segment on this chip", and the playbook is the
only host that answers.
The conversion carries `section`/`when` across (dropping them would turn "add an
instruction" into "quietly widen this ref's scope") and has two shapes: a chip
alone in its paragraph replaces the paragraph, leaving no empty one behind; a
chip among prose leaves the prose as the block's own note.
4. THE DISPATCH EXPLAINER MOVES INTO A `?`.
It was a line under the pill. But it is REFERENCE, not status — identical for
every trigger of a kind, never changing — and a sentence that never changes on a
row you read daily stops being read within a week while still costing a line of
height on every block in the document. Behind a `?` it is one click away on the
day you need it and invisible on the days you do not.
The popover answers two things in the order they are needed: what wakes it
(with the four event glyphs when the trigger is `any`, since "every event" is
the one label that names no thing), and what happens then — whether an
orchestrator reads this block and CHOOSES, or every agent in it simply runs.
4,103 mail tests green; tsc -b clean.
Co-Authored-By: Claude Opus 5 <<email>>Sep 7, 2026, 11:00 PMwebhook-trigger-panel.test.tsxfeat: webhook trigger editor UX (design: playbook-webhooks-and-post-api phase 3)
- WebhookTriggerPanel: mints the webhook row on first pick, shows a copyable POST
URL + curl example, enable toggle, and rotate (pure View + tRPC container).
- TriggerNode renders the panel for webhook-type blocks; TriggerSection hides
conversation field filters for webhook (conversationless).
- Restrict the webhook pill from the legacy [on:] PlaybookTriggerNode (XML path
only); AgentsEditor already scopes its trigger pills.
- RTL test for the panel view (URL shown, rotate + enable handlers fire).
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 25, 2026, 3:08 PM