Skip to content

refactor(web): introduce tool renderer registry#758

Open
heat0206 wants to merge 1 commit into
ColeMurray:mainfrom
heat0206:tool-renderer-registry-clean
Open

refactor(web): introduce tool renderer registry#758
heat0206 wants to merge 1 commit into
ColeMurray:mainfrom
heat0206:tool-renderer-registry-clean

Conversation

@heat0206

@heat0206 heat0206 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduce a simple tool renderer registry and migrate the existing slack-notify custom renderer to use it.

Changes

  • Added a TOOL_RENDERERS registry mapping tool names to custom renderer components.
  • Replaced the hardcoded slack-notify conditional in ToolCallItem with a registry lookup.
  • Preserved the existing generic rendering path for tools without a registered custom renderer.
  • Typed the registry using React.ComponentType<ToolCallItemProps> to support future custom renderers.

Motivation

This is a first step toward supporting additional custom tool renderers without adding more tool-specific conditionals inside ToolCallItem.

With this structure in place, future renderers (such as get-task-status) can be added by registering a component rather than modifying the core rendering logic.

Summary by CodeRabbit

  • Refactor
    • Updated tool rendering logic to streamline how different tool types are handled within the application.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eb42e0d4-8bcc-4b94-8e41-84cc7501843a

📥 Commits

Reviewing files that changed from the base of the PR and between 00f5f2a and 55174ae.

📒 Files selected for processing (1)
  • packages/web/src/components/tool-call-item.tsx

📝 Walkthrough

Walkthrough

ToolCallItem replaces a direct if (event.tool === "slack-notify") branch with a TOOL_RENDERERS registry object that maps tool identifiers to React components. The component now performs a registry lookup and early-returns the matched renderer, falling back to the existing generic logic when no match is found.

Changes

Tool Renderer Registry

Layer / File(s) Summary
TOOL_RENDERERS registry and dispatch logic
packages/web/src/components/tool-call-item.tsx
Adds a TOOL_RENDERERS constant mapping "slack-notify" to SlackNotifyEvent, and replaces the direct conditional check in ToolCallItem with a registry lookup that early-returns the matched renderer.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~3 minutes

Poem

A rabbit built a registry one day,
No more "if slack-notify" in the way!
Each tool finds its renderer neat,
The dispatch map makes it complete.
🐇 Now adding new tools is a treat!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor(web): introduce tool renderer registry' accurately and concisely describes the main change—replacing hardcoded tool-specific logic with a scalable registry pattern.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ColeMurray ColeMurray left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Automated Review]

Thanks @heat0206 — the direction is right and matches what was agreed in #724 (registry first, slack-notify as the first case), and the refactor is behavior-preserving. Before this becomes the foundation that get-task-status and other renderers build on, I want to flag one structural issue with the contract the registry is built on, since it determines whether the pattern actually scales. Line-specific notes are inline; the summary and the apply_patch analysis are here.

The pattern already exists in this file — mirror it

session-timeline.tsx already has this exact registry, one level up, in a cleaner shape:

// session-timeline.tsx — dispatch on event type, shared prop bag, single fallback
const eventRenderers: Partial<Record<SandboxEvent["type"], (props: EventRendererProps) => ReactNode>> = {
  user_message: UserMessageEvent,
  artifact:     ArtifactEvent,   // <- this is the screenshot renderer
  // ...
};
const render = eventRenderers[event.type];
if (!render) return null;
return render({ event, /* ... */ });

Two takeaways:

  1. Screenshots are already a registered renderer — on the event axis, not the tool axis. So the new TOOL_RENDERERS is a sibling registry one level down (dispatch on tool name within a tool_call). Worth being explicit that these are two different axes so future "render types" land in the right one: rich/inline media -> an artifact event (existing path); custom framing of a tool's own result -> a tool renderer.
  2. The new registry should match the established one's conventions (Partial<Record<...>>, render-fn + shared prop bag, single fallback) rather than introduce a parallel Record<string, ComponentType<FullProps>> shape.

apply_patch is the real test — and it doesn't fit the current contract

The genuinely in-disguise tool renderer today is apply_patch, special-cased inside the generic ToolCallItem body (tool-call-item.tsx:66, plus the Patch: block further down). Moving it into the registry is the litmus test for whether the abstraction earns its keep — slack-notify doesn't prove much, because it was already a standalone component.

The catch: apply_patch does not fit the current ComponentType<ToolCallItemProps> contract without duplication. The tell is what each renderer overrides:

renderer icon collapsed summary expanded body
slack-notify custom custom custom
apply_patch generic generic generic + one extra Patch section

slack-notify wants total control, so "render the whole item" fits it by accident. apply_patch wants the generic collapsed line and most of the generic body, overriding only part of the body — and the full-component contract has no way to express partial override. Migrating it as-is forces you to copy the entire shell and re-implement the generic args/output body (and export the private ToolIcon), adding ~50 lines of duplication to delete ~4 lines of branching. Net negative — so in practice it wouldn't get migrated, and the registry would stay a one-entry slack-notify holder.

Under a slot contract it's a one-liner instead:

interface ToolRenderer {
  icon?: (e: ToolCallEvent) => ReactNode;     // optional - defaults to formatToolCall
  summary?: (e: ToolCallEvent) => ReactNode;  // optional - defaults to formatToolCall
  Body: (props: { event: ToolCallEvent }) => ReactNode;
}

const TOOL_RENDERERS: Partial<Record<string, ToolRenderer>> = {
  "slack-notify": { icon: slackIcon, summary: slackSummary, Body: SlackNotifyBody },
  "apply_patch":  { Body: ApplyPatchBody },   // icon/summary fall back to generic
};

ToolCallItem owns the shell and renders the generic body as the default; apply_patch provides only its Body (reusing a shared ToolDetails with an extra patch prop), and the special-casing leaves ToolCallItem entirely.

Recommendation

  • Preferred: adopt the slot contract { icon?, summary?, Body } + extract a shared ToolCallShell / ToolDetails, and migrate apply_patch in this PR as the proof. Adding the next tool is then genuinely one registry line.
  • Acceptable minimum: keep Record<string, ComponentType<...>> but extract a shared ToolCallShell every renderer wraps, so apply_patch fits without shell duplication. (Still re-derives icon/summary per renderer; can't register a body-only tool.)
  • Not recommended: ship the contract as-is — apply_patch can't move in cleanly, so the registry never proves itself and the ToolCallItem special-casing stays.

Happy to provide a concrete diff for the preferred version if that's useful.

}

//Registry
const TOOL_RENDERERS: Record<string, React.ComponentType<ToolCallItemProps>> = {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Automated Review] This Record<string, React.ComponentType<ToolCallItemProps>> typing is the crux of the scalability question. Because the value type is the whole item component, every renderer has to re-implement the collapsible shell (the py-0.5 wrapper, toggle button, chevron rotation, time span, and expanded container) — exactly what SlackNotifyEvent does today, duplicating ~25 lines that are character-identical to ToolCallItem's. With N renderers you get N copies of that shell.

Consider narrowing the contract so a renderer contributes only what varies, and let ToolCallItem own the shell once:

interface ToolRenderer {
  icon?: (e: ToolCallEvent) => ReactNode;     // defaults to formatToolCall
  summary?: (e: ToolCallEvent) => ReactNode;  // defaults to formatToolCall
  Body: (props: { event: ToolCallEvent }) => ReactNode;
}

This also mirrors the existing eventRenderers registry in session-timeline.tsx (render-fn + shared prop bag + Partial<Record<...>>), so the two dispatchers stay consistent. See the main review comment for why apply_patch specifically needs this.

Minor: //Registry -> // Registry (space after //), or drop the comment.


export function ToolCallItem({ event, isExpanded, onToggle, showTime = true }: ToolCallItemProps) {
if (event.tool === "slack-notify") {
const Renderer = TOOL_RENDERERS[event.tool];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Automated Review] Two small things on the lookup key:

  1. Case-sensitivity divergence. This uses the raw event.tool, but the canonical dispatch in tool-formatters.ts normalizes with tool?.toLowerCase(). So a renderer registered here only matches an exact-case tool name, while formatToolCall matches case-insensitively. Not a regression (the old === "slack-notify" was also exact), but it bakes an inconsistency into the new seam — whoever adds get-task-status has to remember the registry is case-sensitive. Suggest keying on event.tool?.toLowerCase() to match the rest of the file.
  2. This const Renderer = registry[key]; if (Renderer) { ... } shape is the same dispatch EventItem already does with eventRenderers in session-timeline.tsx. Aligning the two (same contract shape + fallback) keeps the rendering layer coherent.

@heat0206

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I spent some time digging into ToolCallItem, SlackNotifyEvent, and the current apply_patch handling and I think I understand the concern with the current ComponentType<ToolCallItemProps> contract.

SlackNotifyEvent currently owns the entire shell (toggle, timestamp, expanded container, etc.), while apply_patch only needs a much smaller customization around how its details are rendered. That makes apply_patch a better test of whether the abstraction scales.

I'm exploring a slot-based approach where ToolCallItem owns the shared shell and tool-specific renderers provide only the pieces that vary (body, and potentially icon/summary where needed). I'll continue prototyping that direction and see how it fits both the slack-notify and apply_patch cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants