feat(workflows): brand-marked agent nodes, a graph that reframes itself, and a leaf brand module#174
Conversation
…ed id
An agent node showed whatever string sat in `profile`, humanised. For a profile
named by a readable slug that reads well ("pr-reviewer" → "PR reviewer"), but the
platform mints a stored profile's id as `ap_` + random bytes, and humanising that
produces noise — "ap_NROQux-n7dC7Ll30" became "Ap nro qux n7d c7 ll30". Only the
host holds the catalog that resolves such an id, so the node now stays the generic
agent and lets the host title it.
An agent is identified by WHO runs it, so the node's mark is now the model's brand
(the lab/host glyph the model picker already draws) on both the compact tile and
the expanded card, falling back to the kind glyph for a model with no published
mark — never an invented one.
`modelBrandFor` + `ModelBrandStack` + `resolveModelBrandIdentity` are exported so a
host that knows only a model id — a workflow node panel, a run header — draws the
same mark rather than deriving its own.
…al shadows jsdom's Node defines globalThis.localStorage as an own property that resolves to undefined unless --localstorage-file is passed. It shadows the one jsdom installs, so every component that persists a preference sees undefined and throws — 11 sidebar-collapse tests fail on Node 26 while CI's older Node stays green. Shim it where the other jsdom gaps are shimmed, and drop the two guards written around the hole.
The density toggle already refit the viewport, but instantly — the nodes resize and the canvas jumps, which reads as the graph being replaced rather than reframed. Animate the refit so the same graph is seen moving to its new frame.
… less motion The density toggle glides the viewport to its new framing. Honour prefers-reduced-motion by reframing instantly instead, and stop claiming the effect cleanup covers an in-flight transition — it cancels the scheduled frame, nothing more.
…cker's tail The workflow graph drew a model's brand mark by importing it from the model picker, and rollup cannot split a source module — so the ./workflows entry pulled in the whole picker: @radix-ui/react-dropdown-menu, lucide, the ModelPicker component and its rows. A consumer rendering a graph downloaded a dropdown it never shows. The brand table, the resolution, and the marks now live in their own leaf, which the picker re-exports (the public API is unchanged) and the graph imports directly. The workflows closure drops radix and the picker entirely.
… inside its tile The subtitle already yields to the run's actual model — a router can fall back to another lab — but the brand mark kept drawing the requested one, so a card could show an Anthropic logo beside the words gpt-5.4. The mark now names the same model the subtitle does. A hosted model draws two marks, and the wide stack is 36px against an expanded card's 34px tile, which pushed the lab chip past the border; the wide stack is used only where the tile can hold it. A minted profile id is ap_ + exactly 16 base64url characters. Matching eight-or-more also swallowed a human-authored slug (ap_code_review), replacing a name someone wrote with a generic one.
This comment has been minimized.
This comment has been minimized.
Two shims now share test-setup: main's DataTransfer stand-in and this branch's localStorage one — neither replaces the other. Version goes to 0.81.0, above the two releases main took while this was open.
This comment has been minimized.
This comment has been minimized.
… localStorage probe The brand leaf still reached up into dashboard/ for ProviderLogo, which inverts the layering the leaf exists to establish — and would become a real cycle the day provider-logo grows an import of its own. It carries no dependency but a React type, so it moves down beside the brand table; nothing exported it publicly, so no surface changes. Reading globalThis.localStorage is now guarded. Where it is a real getter it can THROW rather than answer (jsdom raises SecurityError on an opaque origin), and an unguarded probe takes the setup file down before the replacement it is probing for can be installed.
…what a reader never opted into The barrel exposed the resolver, the brand keys and the brand-info shape alongside the mark. A caller holds a model STRING — modelBrandFor plus the mark is the whole surface it needs, so the ModelInfo-shaped resolver and the table behind it go back to being implementation. The reframe also animated wherever the motion preference could not be READ, which is the wrong default: motion is opt-out, and a place with no matchMedia has not told us the reader tolerates it.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… its own tests The version moved to 0.81.0 in the merge with main without the changelog following it. modelBrandFor is the whole surface a caller holding a model string has, and the ids come off a router rather than a picker — so the shapes it can arrive in (hosted, bare, unknown, empty, separators only) each get a test rather than being covered only through a node render.
This comment has been minimized.
This comment has been minimized.
…mplying the mark is announced A minted id is ap_ + exactly 16 characters, and the boundary is where an off-by-one hides: one short and a real id gets humanised into noise, one long and somebody's name becomes "AI Agent". Both sides now have a test. The brand tests found the mark with getByLabelText, which does not prune an aria-hidden subtree — so they asserted an accessible name a screen reader never receives. They now query the DOM directly, and the contract they were implying is stated outright: the tile is decorative BECAUSE the model is already announced in words, so the card reads "AI Agent, claude-sonnet-4-5" rather than saying it twice. Built through buildWorkflowGraph so the subtitle under test is the real one.
This comment has been minimized.
This comment has been minimized.
…n the reframe wiring The in-memory localStorage was argued for in a comment and asserted nowhere. It now lives in a helper the tests can reach, and the two ways a host fails to provide one each have a test: it is missing (Node's own global, undefined without --localstorage-file) and it THROWS on read (jsdom on an opaque origin, where an unguarded probe takes the setup file down before the replacement can be installed). Both end with a Storage a beforeEach can call clear() on without a guard around it. The reframe helper was unit-tested but its CALL was not: handing React Flow the bare constant instead of the animated framing would have gone unnoticed. A stubbed React Flow — it measures a viewport jsdom does not have, and is not the thing under test — now catches exactly that. The minted-id comment claimed more than the regex checks: it is a shape, not a validation of the bytes behind it.
This comment has been minimized.
This comment has been minimized.
…e inherited untested resolveModelBrandIdentity decides who HOSTS a model and who MADE it, almost entirely by precedence — explicit logos, then hostProvider, then the router's provider fields, then the id itself; and the lab from modelLab before the id implies one. A precedence chain reorders silently under a refactor, and this one came through the extraction with no test of its own. Each rung has one now, along with the combined flag the card reads to decide between a single glyph and a stack, and a caller's own artwork overriding the bundled monochrome mask.
This comment has been minimized.
This comment has been minimized.
The guards were removed because the setup installs an in-memory Storage where the host has none — including where reading localStorage throws. That is a contract, not luck (setupFiles runs before every test file), but nothing at the call site said so.
🤖 AI Code Review (ensemble)
SummaryThe PR extracts brand-resolution logic into a leaf module to improve bundle splitting, adds model-branded agent node marks, and implements animated viewport reframing on density toggle. It includes targeted tests for the new logic. Reviewer A flagged two P1s: a missing re-export and a dangling import. However, inspecting the diff shows the re-export for No Issues Found✅ APPROVEReviewers B and C approved without findings, while Reviewer A's two P1s are both contradicted by the diff: Synthesized by Sokuza AI from multiple independent reviewers Independent re-review of the branch, cold, agrees. Checked rather than assumed:
Merging publishes 0.81.0 ( |
tangletools
left a comment
There was a problem hiding this comment.
🟢 Value Audit — sound
| Verdict | sound |
| Concerns | 0 (none) |
| Heuristic | 0.0s |
| Duplication | 0.0s |
| Interrogation | 427.3s (2 bridge agents) |
| Total | 427.3s |
💰 Value — sound
Extracts model-brand resolution into a leaf module so workflow agent nodes can show a model's brand mark without dragging the whole picker into the bundle, plus reframes the graph on density toggle — coherent, grain-aligned, real bundle win.
- What it does: Three things. (1) An agent node whose profile is a minted catalog id (ap_ + 16 base64url chars) was titled by humanising that id into noise ('Ap nro qux n7d c7 ll30'); it is now the generic 'AI Agent' marked with its model's brand logo, resolved from the model slug alone via a new modelBrandFor(string) (src/workflows/model.ts:374-384, src/workflows/node-ui.tsx:199-234). (2) The brand table, types,
- Goals it achieves: Make an agent node legible at a glance — WHO runs it (the brand mark) instead of a mangled database id or a generic sparkle — and keep the ./workflows bundle closure lean while doing it (130.4 KB -> 113.1 KB, dropping @radix-ui/react-dropdown-menu and the ModelPicker component per the PR body). Secondary: fix the mis-zoomed graph left behind by a density flip, and clean up test localStorage guards
- Assessment: A well-executed change in the grain of the codebase. The leaf-module extraction is the correct and only way to break a rollup source-module closure (rollup cannot split a file), and the re-export-from-picker pattern preserves the public API — verified against src/dashboard/index.ts:134-143 which adds modelBrandFor/ModelBrandStack/ModelBrandIdentity as named exports. modelBrandFor's null-on-no-mark
- Better / existing approach: none — this is the right approach. Searched for an existing brand-resolution utility: the logic was extracted FROM model-picker.tsx (not reinvented), and no other module in src/ or node_modules/@tangle-network/ui resolves model brands. The two provider-logo files (lib/ for model-lab SVG path data, integrations/ for connector logos) serve genuinely different surfaces — model labs vs integration con
- Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 2
- Bridge warning: opencode/kimi-for-coding/k2p7: bridge stream ended without value-audit content
🎯 Usefulness — sound
Brand-marked agent nodes, density-aware refit, and a leaf brand module all wire into the live WorkflowGraph render path and factor out existing logic rather than duplicating it.
- Integration: Fully reachable: NodeMark (node-ui.tsx:201) calls modelBrandFor and is itself rendered by WorkflowGraph.tsx:374,450 with model={markModel}; the density refit fires through the structural memo (WorkflowGraph.tsx:620-627) whose deps include compact, hitting the [structural] effect at line 675. The existing chat consumer (agent-session-controls.tsx:18) keeps working via the picker's re-export. The ./
- Fit with existing patterns: Matches the codebase grain. CLAUDE.md frames this repo as a thin bridge that re-exports generic surfaces and authors only sandbox-coupled ones; factoring the brand table out of model-picker.tsx into src/lib/model-brand.tsx (a radix/lucide-free leaf) with the picker re-exporting it is exactly that pattern, and it cures a real bundle leak — ./workflows no longer imports model-picker or dashboard (gr
- Real-world viability: Holds up off the happy path. modelBrandFor trims, handles empty/whitespace/slash-only inputs returning null (tested at model-brand.test.ts:35-44), and a null result falls through to the kind glyph (node-ui.tsx:222-233) rather than rendering an empty chip. fitViewOnLayoutChange guards both typeof window and matchMedia optionality for SSR/non-browser hosts. The MINTED_PROFILE_ID regex is anchored at
- Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 1
No concerns — sound change, no better or existing approach found. ✅
What this audit checks
It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.
| Pass | What it asks |
|---|---|
| Heuristic | Vague title? Whitespace-only or cruft-bearing diff? (content signals only) |
| Duplication | Do added function/class names already exist elsewhere in the repo? |
| Value Audit | What does it do? What goal does it achieve? Is it good? Better architecture or already-exists? |
| Usefulness Audit | Does it integrate and fit? Will it hold up in real use and actually get used? |
Findings are concerns, not blocks — the human reviewer decides what to do with them.
✅ No Blockers —
|
tangletools
left a comment
There was a problem hiding this comment.
✅ Approved — 13 non-blocking findings — 2866dfde
Full multi-shot audit completed 8/8 planned shots over 18 changed files. Global verifier still owns final merge decision.
Full immutable report for this review: trace
Summary comment for this run: full summary
tangletools · 2026-07-14T17:50:49Z · immutable trace
Overview
An agent node in the workflow graph now says who runs it — its model's brand mark, next to a name a person can read — and the graph reframes itself when you flip between compact and expanded density instead of leaving you mis-zoomed.
Cut for tangle-network/agent-dev-container#3543 (the expanded workflow node reads badly). The platform side pins
^0.78.0, so this needs to merge and publish before that PR can install.The changes
Brand marks on agent nodes (
fd4cfd9). A node whose profile is a minted catalog id (ap_+ 16 random chars) was titled by humanising that id — "Ap nro qux n7d c7 ll30", which names nothing. It is now the generic "AI Agent", marked with the logo of the model it runs, resolved from the model slug alone. A model with no published mark keeps the kind glyph rather than getting an invented one.The graph reframes on the density toggle (
1aa6d85,fe83fa4). Node ids and count don't change across a density flip, so React Flow won't auto-fit and the graph was left mis-zoomed. It now refits — and glides to the new framing rather than jumping, because the reader is watching. Skipped entirely underprefers-reduced-motion.The brand marks are a leaf module (
e9bf76a). Drawing a mark meant importing fromdashboard/model-picker, and rollup cannot split a source module — so the./workflowsentry pulled in the entire picker:@radix-ui/react-dropdown-menu, lucide, theModelPickercomponent and its rows. A consumer rendering a workflow graph downloaded a dropdown it never shows. The brand table, the resolution and the marks now live insrc/lib/model-brand.tsx; the picker re-exports them, so the public API is unchanged../workflowsclosure@radix-ui/react-dropdown-menuModelPicker(The residual weight is the brand artwork itself — the lobehub SVGs are the glyph. Radix and lucide are
external, so dropping them also removes them from a consumer's dependency graph, which the KB figure understates.)localStoragein tests (42691d2). Node ships its ownglobalThis.localStorage, and it isundefinedunless the process was started with--localstorage-file. Vitest's jsdom environment skips any key already present on the global, so jsdom's Storage was never installed and anything persisting a preference threw.mainis red on Node 26 today (18 tests, includingsidebar-collapse.test.tsx, which this branch doesn't touch) and green in CI only because CI pins Node 22.Two fixes from review (
31ede5c): the mark drew the requested model while the subtitle already showed the actual one, so a run that fell back to another lab showed an Anthropic logo beside the words "gpt-5.4". And a hosted model's two-mark stack is 36px against an expanded card's 34px tile, so the lab chip hung over the border.Verification
pnpm typecheckclean;pnpm test744/744..d.tsexport lists identical.Note for the reviewer
fitViewOnLayoutChangeis exported so the reduced-motion behaviour can be tested; it is not part of a public entry point.