Composer Button Extensions
The Composer Button Framework allows you to create custom buttons in the Composer input area (next to the Send button) without modifying core Chatons code. This guide covers architecture, APIs, and implementation patterns.
Overview
Composer buttons are extension-based additions to the Composer that provide:
- Context usage ring showing model context window consumption
- Microphone button for Speech-to-Text transcription
- Custom AI tools for specialized tasks
- Model shortcuts for quick model switching
- Extensible framework for any composer-based action
All buttons integrate seamlessly with Chatons' design system and support both icon and widget rendering modes, plus requirement sheets for setup workflows.
Architecture
Core Components
SDK (composer-button-sdk.ts)
└─ Registry + Interfaces
├─ ComposerButtonExtension
├─ ComposerButtonAction
├─ ComposerButtonContext
└─ ComposerButtonRequirement
Hook (use-composer-extension-buttons.ts)
└─ State management + requirement checking
├─ getAllButtons()
├─ executeButtonAction()
└─ requirement sheet display
Component (ComposerExtensionButtons.tsx)
└─ UI rendering
└─ Button icons + interactions
Composer (Composer.tsx)
└─ Integration point
├─ Hook usage
└─ ComposerRequirementSheet display
Data Flow
- Initialization:
startExtensionLoader()registers built-in extensions - Discovery: Hook subscribes to registry changes
- Rendering: ComposerExtensionButtons renders available buttons
- Execution: Click triggers
executeButtonAction() - Requirements: Check if requirements satisfied
- Display: Show requirement sheet if needed
- Action: Execute button's
onAction()callback
Core Interfaces
ComposerButtonExtension
interface ComposerButtonExtension {
id: string; // Unique extension ID (e.g., '@vendor/name')
name: string; // Display name
version: string; // Semantic version
getButtons(): Promise<ComposerButtonAction[]>; // Get available buttons
onEnable?(): void; // Called when enabled
onDisable?(): void; // Called when disabled
}
ComposerButtonAction
interface ComposerButtonAction {
id: string; // Unique button ID
label: string; // Display label
icon: string; // Lucide icon name (used in icon mode)
tooltip?: string; // Hover tooltip
renderMode?: 'icon' | 'widget'; // 'icon' (default) or 'widget' for custom HTML
widgetHtml?: string; // HTML string rendered in an iframe (widget mode)
widgetSize?: { width: number; height: number }; // Widget dimensions (default 32x32)
requirements?: ComposerButtonRequirement[]; // Setup requirements
onAction(context: ComposerButtonContext): Promise<void>;
}
ComposerButtonContext
interface ComposerButtonContext {
conversationId: string | null;
projectId: string | null;
setText(text: string, append?: boolean): void;
getText(): string;
addAttachment(file: File): Promise<void>;
sendMessage(): Promise<void>;
notify(title: string, body?: string, type?: 'info' | 'success' | 'error' | 'warning'): void;
getCurrentModel(): Promise<{ provider: string; id: string } | null>;
getAvailableModels?(): Promise<Array<{ provider: string; id: string; name: string; capabilities?: string[] }>>;
showRequirementSheet?(requirement: ComposerButtonRequirement): Promise<'confirm' | 'dismiss' | 'open-settings'>;
accessMode: 'secure' | 'open';
contextUsage: ComposerContextUsageData | null; // Live token usage stats
}
ComposerContextUsageData
interface ComposerContextUsageData {
usedTokens: number; // Estimated tokens currently occupying the thread context
contextWindow: number; // Model context window capacity (0 if unknown)
percentage: number; // Percentage of context window used (0-100)
}
ComposerButtonRequirement
interface ComposerButtonRequirement {
id: string; // Unique requirement ID
title: string; // Display title
html: string; // HTML content (displayed in iframe)
satisfied(): Promise<boolean>; // Check if satisfied
}
Requirement Sheets
Requirement sheets are optional setup workflows displayed before button execution. They:
- Display in a modal sliding from the top
- Use Chatons' official CSS classes and styling
- Support light and dark modes automatically
- Allow HTML content via iframe sandboxing
- Communicate via postMessage for user actions
HTML Content Requirements
Requirement sheet HTML is rendered in an iframe sandbox with:
sandbox="allow-scripts allow-same-origin allow-forms"
Send messages to parent for user actions:
// User confirmed
window.parent.postMessage({ type: 'chaton:requirement-sheet:confirm' }, '*');
// User dismissed
window.parent.postMessage({ type: 'chaton:requirement-sheet:dismiss' }, '*');
// User wants to open settings
window.parent.postMessage({ type: 'chaton:requirement-sheet:open-settings' }, '*');
Styling Requirements Sheets
Use Chatons' official color palette:
Light Mode:
- Text:
#222632 - Secondary:
#4a4f5f - Primary button:
#2563eb - Status box:
#f0fdf4bg +#22c55eborder
Dark Mode:
- Add
.darkprefix to selectors:.dark body { color: #e6ecfa; } .dark .btn-primary { background: #3b82f6; }
Max Height
Requirement sheets use 80% of viewport height (80vh) for maximum content visibility while leaving space for the Composer below.
Example: Speech-to-Text Extension
The built-in Speech-to-Text extension demonstrates best practices:
const extension: ComposerButtonExtension = {
id: '@thibautrey/chatons-extension-speech-to-text',
name: 'Speech to Text',
version: '1.0.0',
async getButtons(): Promise<ComposerButtonAction[]> {
return [createSpeechToTextButton()];
},
};
function createSpeechToTextButton(): ComposerButtonAction {
let hasShownRequirementSheet = false;
return {
id: 'speech-to-text-button',
label: 'Speech to Text',
icon: 'Mic',
tooltip: 'Click to dictate your message (🎙️)',
requirements: [
{
id: 'speech-to-text-setup',
title: 'Speech to Text',
html: generateSpeechToTextRequirementHTML(),
satisfied: async () => hasShownRequirementSheet,
},
],
async onAction(context: ComposerButtonContext) {
hasShownRequirementSheet = true;
const SpeechRecognition = (window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
context.notify('Speech Recognition Not Available', 'Your browser does not support the Web Speech API.', 'error');
return;
}
const recognition = new SpeechRecognition();
recognition.lang = navigator.language || 'en-US';
recognition.continuous = false;
recognition.interimResults = true;
return new Promise<void>((resolve) => {
let finalText = '';
recognition.onstart = () => {
context.notify('Listening...', 'Speak now. Your speech will be transcribed.', 'info');
};
recognition.onresult = (event: any) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += transcript + ' ';
}
}
};
recognition.onend = () => {
if (finalText.trim()) {
context.setText(finalText.trim() + ' ', true);
context.notify('Speech Recognized', `"${finalText.trim()}"`, 'success');
} else {
context.notify('No speech detected', 'Please try again', 'warning');
}
resolve();
};
recognition.onerror = (event: any) => {
context.notify('Speech Recognition Error', event.error, 'error');
resolve();
};
recognition.start();
});
},
};
}
Creating Your Own Extension
1. Define Your Extension
Icon Mode (default)
import { ComposerButtonExtension } from '@/extensions/composer-button-sdk';
const myExtension: ComposerButtonExtension = {
id: '@myorg/my-composer-button',
name: 'My Custom Button',
version: '1.0.0',
async getButtons() {
return [
{
id: 'my-button',
label: 'My Action',
icon: 'Zap',
tooltip: 'Do something awesome',
async onAction(context) {
// Your implementation here
const text = context.getText();
context.setText(text + ' [processed]', false);
context.notify('Done!', 'Text processed', 'success');
},
},
];
},
};
Widget Mode
Widget mode renders custom HTML in an iframe instead of a Lucide icon. This is useful for rich visual elements like progress indicators, status badges, or mini-charts.
const myWidgetExtension: ComposerButtonExtension = {
id: '@myorg/my-widget',
name: 'My Widget',
version: '1.0.0',
async getButtons() {
return [
{
id: 'my-widget-button',
label: 'Status Widget',
icon: 'Circle', // Fallback, not shown in widget mode
renderMode: 'widget',
widgetHtml: `
<!DOCTYPE html>
<html><head>
<style>
body { margin: 0; background: transparent; }
.dot { width: 12px; height: 12px; border-radius: 50%;
background: #22c55e; margin: 10px auto; }
</style>
</head><body>
<div class="dot" id="dot"></div>
<script>
window.addEventListener('message', function(e) {
if (e.data?.type !== 'chaton.composerButton.context') return;
// React to context updates
var usage = e.data.payload?.contextUsage;
if (usage && usage.percentage > 75) {
document.getElementById('dot').style.background = '#d97706';
}
});
</script>
</body></html>
`,
widgetSize: { width: 32, height: 32 },
async onAction() {
// Widget is display-only
},
},
];
},
};
Widget Communication Protocol
Widgets receive context updates from the host via postMessage:
Host to Widget (automatic on every context change):
{
type: 'chaton.composerButton.context',
payload: {
buttonId: 'my-widget-button',
conversationId: 'uuid' | null,
projectId: 'uuid' | null,
contextUsage: {
usedTokens: 1245,
contextWindow: 4096,
percentage: 30
} | null
}
}
Widget to Host (optional, for dynamic tooltips):
window.parent.postMessage({
type: 'chaton.composerButton.tooltip',
buttonId: 'my-widget-button',
text: 'Current status: OK'
}, '*');
The host renders the tooltip text as a native title attribute on the widget container, so it appears on hover without the iframe needing to overflow.
2. Register Your Extension
For built-in extensions, register in registerBuiltInExtensions():
function registerBuiltInExtensions(): void {
composerButtonRegistry.register(myExtension);
}
3. Add Requirements (Optional)
requirements: [
{
id: 'my-setup',
title: 'Setup Required',
html: `<html>...<button onclick="window.parent.postMessage({type:'chaton:requirement-sheet:confirm'},'*')">OK</button></html>`,
satisfied: async () => {
// Check if setup is complete
return localStorage.getItem('my-setup-done') === 'true';
},
},
],
4. Use Context Methods
async onAction(context) {
// Get current state
const text = context.getText();
const model = await context.getCurrentModel();
const models = await context.getAvailableModels?.();
// Read context usage
const usage = context.contextUsage;
if (usage && usage.percentage > 80) {
context.notify('Warning', 'Context window almost full', 'warning');
}
// Modify composer
context.setText('new text', false); // Replace
context.setText(' appended', true); // Append
// Attach files
await context.addAttachment(file);
// Notify user
context.notify('Title', 'Body', 'success');
// Send message
await context.sendMessage();
// Show requirement sheet
const result = await context.showRequirementSheet?.(requirement);
}
Built-in: Context Usage Widget
The context usage circular progress ring is implemented as a built-in widget-mode composer button (@chaton/context-usage). It shows an estimate of how much of the active model's context window is currently occupied by the thread.
The widget:
- Receives
contextUsagedata viachaton.composerButton.contextmessages - Renders a circular SVG progress ring (24x24 in a 32x32 container)
- Changes stroke color based on usage level: default (slate), warning at 75% (amber), danger at 90% (red)
- Sends dynamic tooltip text back to the host via
chaton.composerButton.tooltip
This extension demonstrates the full widget pattern and can be used as a reference for building similar visual widgets.
Best Practices
UI/UX
- Use Lucide icons for icon-mode buttons (Mic, Zap, Filter, etc.)
- Use widget mode for rich visual elements (progress rings, status indicators)
- Provide clear tooltips explaining what the button does
- Show notifications for user feedback
- Keep actions fast and responsive
Widget Mode
- Keep widgets small and focused (32x32 default)
- Use
pointerEvents: nonefor display-only widgets (the host handles this) - Send tooltip updates via
chaton.composerButton.tooltipfor hover text - React to
chaton.composerButton.contextmessages for live data - Use transparent backgrounds to blend with the composer
Requirements
- Show requirements only once (use a flag or localStorage)
- Provide clear setup instructions in the requirement sheet
- Use Chatons' official colors for visual consistency
- Support both light and dark modes with
.darkCSS
Performance
- Lazy-load expensive operations in
onAction() - Clean up resources in error handlers
- Use Promise properly to avoid hanging actions
- Cache results when possible (requirements.satisfied state)
Accessibility
- Use semantic HTML in requirement sheets
- Provide ARIA labels for screen readers
- Support keyboard navigation (Escape to close)
- Maintain high contrast in both themes
File Locations
SDK: src/extensions/composer-button-sdk.ts
Hook: src/hooks/use-composer-extension-buttons.ts
Component: src/components/shell/composer/ComposerExtensionButtons.tsx
Requirement Sheet: src/components/shell/composer/ComposerRequirementSheet.tsx
Composer: src/components/shell/Composer.tsx
CSS: src/styles/components/chat.css
Troubleshooting
Button Not Appearing
- Check browser console for
[Composer Extensions]messages - Verify extension is registered in
registerBuiltInExtensions() - Ensure
getButtons()returns non-empty array - Restart Chatons
Requirement Sheet Not Showing
- Check
satisfied()returnsfalseon first click - Verify HTML is valid and renders in iframe
- Check browser console for errors
- Verify postMessage communication works
Button Click Not Working
- Check
onAction()implementation - Verify context methods are available
- Check for TypeScript errors
- Review browser console logs