Skip to content

feat(plugins): timeline renderers, banners, code blocks and themes from plugins - #20

Draft
ashx-j wants to merge 10 commits into
mainfrom
feat/plugins-phase-4c
Draft

ashx-j wants to merge 10 commits into
mainfrom
feat/plugins-phase-4c

Conversation

@ashx-j

@ashx-j ashx-j commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Phase 4c of the plugin system roadmap (#3, phase issue #8). It builds on #19, so until #14 to #19 merge this diff also shows their commits.

Web plugins could add panels and commands, but couldn't change how T3 looks or how conversations read. This lets a plugin add themes, banners and its own way of drawing tool calls and code blocks.

  • Themes. A plugin's themes/ folder is published through the existing environment theme service, so its themes appear in the theme picker on web, desktop and mobile with no client change. The environment's own themes/ folder wins when ids collide, and a disabled plugin's themes disappear.
  • Composer banners. api.composer.showBanner({ title, description, variant }) shows a banner above the composer in every thread until the plugin hides it, the user dismisses it, or the plugin unloads. It goes through useSystemComposerBanners, so ChatView is unchanged.
  • Tool call renderers. api.timeline.registerToolRenderer({ tool, component }) draws the plugin's own MCP tool calls (plg_<pluginId>_<tool>) in the timeline's tool details. Only plg_ tools mount the lookup, so other tool rows read no registry. A renderer that throws falls back to the generic view.
  • Code block renderers. api.markdown.registerCodeBlock({ language, component }) draws fenced code of that language in chat messages, for example a chart from ```hello-chart. It only replaces closed fences, so streaming text renders as code until the fence ends. mermaid and math stay T3's own.
  • Styles. api.injectStyles(css) adds a stylesheet that is removed when the plugin unloads.

Changes from the roadmap (issue #8 is updated):

  • The markdown hook is a renderer for fenced code languages rather than a general post-processor over assistant text. Code fences are the seam Mermaid and math already use, and a general text pass would run on every message render, the hottest path in the app.
  • Not built yet, left for a follow-up:
    • Thread header badges and status indicators. They need a new field in the thread shell projection on the server.
    • Sidebar sections. The sidebar is one tree whose drag and drop logic knows every section by name.
    • The items moved from 4b: composer context chips, settings and state atoms, commands.run for built-in commands.

Measurements:

Bundle, from production web builds of #19's head and this branch, compared chunk by chunk:

  • useTimelineFollow (the timeline chunk) grew 1.0 KB: the plugin tool body and its check in the tool details.
  • main grew 0.8 KB: the banner, tool renderer and code block registries.
  • pluginLoader grew 0.7 KB, _chat 0.3 KB and ChatMarkdown 0.2 KB.

Renders: not measured with a profiler. Chat code blocks read one atom that changes only when a plugin adds or removes a code block renderer. It is not the shared registry version, so banners and panels never re-render code blocks. Tool rows for non-plugin tools read nothing new. The sidebar did not change.

Video and screenshots:

GitHub's CLI can't upload images or video, so a 30-second recording and 2 screenshots are in the T3 thread for this work instead: /hello showing a plugin banner, the hello-web panel with its asset text and injected style, a chart drawn from a hello-chart fence in a message, and "Hello Dusk" in the theme picker. They still need to be dragged into this description during review.

Test plugin: hello-web, which now also ships themes/hello-dusk.json, shows a banner from /hello, draws hello-chart fences, injects a style, and has an MCP tool greet with a timeline renderer.

Verification:

  • environmentTheme.test.ts covers plugin theme folders: they are added after the environment's own, the environment wins on the same id, and they disappear when cleared. The PluginHost.test.ts web files case checks the host publishes an active plugin's themes/ folder and clears it on disable.
  • PluginToolBody.browser.test.tsx checks a registered renderer draws the tool call, the generic view returns after unloading, and a throwing renderer falls back.
  • A new ChatMarkdown.browser.test.tsx case checks a plugin draws a closed hello-chart fence and leaves an open, streaming one as code.
  • pluginLoader.browser.test.ts loads hello-web and checks its banner, tool renderer and stylesheet are added and removed on unload.
  • In a browser against a dev server on seeded data, using the free Replay provider: everything in the video above, with no paid agent turn.
  • vp run check:changed passed.
  • Not verified: a plugin tool call drawn live, because that needs an agent to call the tool, and the desktop app.

Review: an adversarial review of #18 to #22 found issues in this PR; the fixes are in #23.


PR filed by Claude Opus 5.5 in Claude Code through T3 Code.

ashx-j and others added 10 commits October 31, 2026 21:22
…bility flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginHost discovers installed and dev plugins, loads enabled CommonJS
entries after the server is listening, and hands each a Promise-based API
for logging, settings, storage, RPC methods and channels. Failures mark the
plugin failed and never stop the server. Settings live beside T3's state
with secrets in the server secret store; storage is a lazily opened SQLite
file. Eight plugins.* WebSocket methods manage plugins, and the environment
advertises plugins: true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adversarial review found that reloading one plugin left plugins whose
folders vanished running, that a caller disconnecting mid-disable left a
plugin half torn down but still routed, that a misspelled access level
made a write method callable by read-only sessions, and that reload kept
old code for symlinked plugin folders. Lifecycle changes now run in the
host's scope, vanished plugins are stopped, access must be read or write,
and discovery resolves real paths. Activation and cleanups get deadlines,
calls stop routing before teardown, and plugins.json keeps unknown keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings gains a Plugins page for the selected environment: each plugin
with its status, failure, enable switch and reload, plus a settings form
generated from the manifest's JSON Schema with write-only secret fields
and a JSON fallback. The page follows plugins.subscribeStates only while
open; the form model lives in client-runtime for the later mobile screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng and send hooks

Plugins can now follow stored orchestration events by type, read and act
on threads and projects, run scheduled work, see and extend outgoing
messages before they are sent, offer MCP tools to agents and serve HTTP
routes under /api/plugins/<id>, and read the server's version, descriptor
and redacted settings. The send hook runs in ThreadManagementService.dispatch
with a 2 s deadline and fails open; with no hooks it is one Ref read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… writes

An adversarial review found that a send hook appending to /goal clear
turned it into setting a goal, that the SDK promised a veto nothing
honoured, that several event types merged out of sequence order, that a
schedule could outlive its plugin, and that plugin write tools skipped the
caller's mode limits. Hooks now skip slash commands, veto is gone from the
SDK, multi-type event streams stay ordered, schedules and hooks stop on
the deactivation signal, API calls end after cleanups, plugin write tools
need full access like environment writes, and tool schemas must be objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plugins could only run on the server. The server now serves each active
plugin's web bundle, and the web app loads it once an active plugin has a
web part: a starter follows plugin state after idle, and the loader fetches
bundles with the connection's credentials and imports them from blob URLs.
Bundles share the app's React, atom hooks and UI kit through a host object.

Plugins can add right panels, listed in the add menu and the empty panel,
and replace their generated settings form. An unloaded plugin's panel shows
"Plugin not active" with a close button. The desktop CSP allows blob scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web plugins could add panels but nothing users could run from the
keyboard. Plugins can now register command palette commands that users
bind as plugin.<pluginId>.<commandId>, and slash commands in the composer
that run plugin code. They can also navigate and load files from their
assets folder, which the server serves under /api/plugins/<id>/assets/*.

The SDK gains a Vite preset that builds web.js against T3's React and UI
kit, keeping only the names a plugin uses, and typed UI components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om plugins

Web plugins could add panels and commands but not change how T3 looks or
how conversations read. A plugin's themes/ folder is now published through
the environment theme service, so its themes reach every client. Web
plugins can show composer banners, draw their own MCP tool calls in the
timeline, draw fenced code of a language in chat messages, and inject
styles. Everything is removed when the plugin unloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ashx-j
ashx-j force-pushed the feat/plugins-phase-4c branch from cf577cd to 5782adc Compare October 31, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant