diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx
index 65e7e1c2097..c70f8d90f30 100644
--- a/packages/web-shell/client/App.test.tsx
+++ b/packages/web-shell/client/App.test.tsx
@@ -14,6 +14,7 @@ import type {
VoiceStatusRevision,
VoiceWorkspaceTarget,
} from './voice/voice-workspace-target';
+import type { WebShellComposerToolbarRenderInfo } from './customization';
import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl';
type StreamingState = 'idle' | 'responding';
@@ -420,8 +421,8 @@ vi.mock('./components/ChatEditor', async () => {
focus: editorFocus,
}));
return React.createElement(
- React.Fragment,
- null,
+ 'div',
+ { 'data-web-shell-composer': '' },
React.createElement(
'button',
{
@@ -1467,6 +1468,8 @@ beforeEach(() => {
mockConnection.workspaceCwd = '/tmp/project';
mockConnection.status = 'connected';
mockConnection.displayName = 'Session One';
+ mockConnection.currentMode = 'default';
+ mockConnection.currentModel = 'qwen';
mockConnection.error = undefined;
mockConnection.errorStatus = undefined;
mockConnection.missingSession = false;
@@ -1612,6 +1615,134 @@ afterEach(() => {
vi.restoreAllMocks();
});
+describe('App composer footer renderer', () => {
+ it('passes composer state and keeps the header, composer, and footers ordered', async () => {
+ const composerFooterProps: WebShellComposerToolbarRenderInfo[] = [];
+ const ComposerFooter = (props: WebShellComposerToolbarRenderInfo) => {
+ composerFooterProps.push(props);
+ return
composer footer
;
+ };
+ const { container } = renderApp({
+ renderComposerHeader: () => (
+ composer header
+ ),
+ renderComposerFooter: ComposerFooter,
+ renderFooter: () => shell footer
,
+ });
+ await flush();
+
+ expect(composerFooterProps.at(-1)).toEqual({
+ disabled: false,
+ isRunning: false,
+ currentMode: 'default',
+ currentModel: 'qwen',
+ sessionName: 'Session One',
+ });
+
+ const composer = container.querySelector('[data-web-shell-composer]');
+ const composerHeader = container.querySelector(
+ '[data-testid="composer-header"]',
+ );
+ const composerFooter = container.querySelector(
+ '[data-testid="composer-footer"]',
+ );
+ const shellFooter = container.querySelector('[data-testid="shell-footer"]');
+
+ expect(composer).not.toBeNull();
+ expect(composerHeader?.parentElement?.nextElementSibling).toBe(composer);
+ expect(composer?.nextElementSibling).toBe(composerFooter);
+ expect(composerFooter?.parentElement).toBe(composer?.parentElement);
+ expect(composer?.parentElement?.nextElementSibling).toBe(shellFooter);
+ });
+
+ it('updates composer footer state and renders it in the empty welcome state', async () => {
+ const composerFooterProps: WebShellComposerToolbarRenderInfo[] = [];
+ const ComposerFooter = (props: WebShellComposerToolbarRenderInfo) => {
+ composerFooterProps.push(props);
+ return ;
+ };
+ const { container, rerender } = renderApp({
+ renderComposerFooter: ComposerFooter,
+ });
+ await flush();
+
+ testState.streamingState = 'responding';
+ mockConnection.currentMode = 'plan';
+ mockConnection.currentModel = 'qwen-next';
+ mockConnection.displayName = 'Session Two';
+ rerender({ renderComposerFooter: ComposerFooter });
+ await flush();
+
+ expect(composerFooterProps.at(-1)).toEqual({
+ disabled: false,
+ isRunning: true,
+ currentMode: 'plan',
+ currentModel: 'qwen-next',
+ sessionName: 'Session Two',
+ });
+
+ mockConnection.catchingUp = true;
+ rerender({ renderComposerFooter: ComposerFooter });
+ await flush();
+
+ expect(composerFooterProps.at(-1)).toEqual({
+ disabled: true,
+ isRunning: true,
+ currentMode: 'plan',
+ currentModel: 'qwen-next',
+ sessionName: 'Session Two',
+ });
+
+ mockConnection.catchingUp = false;
+ testState.streamingState = 'idle';
+ mockConnection.sessionId = undefined;
+ mockConnection.displayName = undefined;
+ rerender({ renderComposerFooter: ComposerFooter });
+ await flush();
+
+ expect(
+ container.querySelector('[data-testid="composer-footer"]'),
+ ).not.toBeNull();
+ expect(composerFooterProps.at(-1)).toEqual({
+ disabled: false,
+ isRunning: false,
+ currentMode: 'plan',
+ currentModel: 'qwen-next',
+ sessionName: undefined,
+ });
+ });
+
+ it('does not add composer footer DOM when omitted or when the renderer returns null', async () => {
+ const { container, rerender } = renderApp();
+ await flush();
+
+ const composer = container.querySelector('[data-web-shell-composer]');
+ const composerChildren = Array.from(
+ composer?.parentElement?.children ?? [],
+ );
+ expect(composer?.nextElementSibling).toBeNull();
+
+ rerender({ renderComposerFooter: () => null });
+ await flush();
+
+ const nullComposer = container.querySelector('[data-web-shell-composer]');
+ const nullComposerChildren = Array.from(
+ nullComposer?.parentElement?.children ?? [],
+ );
+ expect(nullComposer?.nextElementSibling).toBeNull();
+ expect(nullComposerChildren).toHaveLength(composerChildren.length);
+ expect(
+ nullComposerChildren.map((child) =>
+ child.getAttribute('data-web-shell-composer'),
+ ),
+ ).toEqual(
+ composerChildren.map((child) =>
+ child.getAttribute('data-web-shell-composer'),
+ ),
+ );
+ });
+});
+
describe('App shell command queueing', () => {
it('lazily creates a session for ! shell commands in a new task', async () => {
mockConnection.sessionId = undefined;
diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx
index fe9da16c263..3064bad47e1 100644
--- a/packages/web-shell/client/App.tsx
+++ b/packages/web-shell/client/App.tsx
@@ -271,6 +271,7 @@ import {
type ComposerToolbarEndRenderer,
type ComposerToolbarRightRenderer,
type ComposerHeaderRenderer,
+ type ComposerFooterRenderer,
type ChatHeaderRenderer,
type FooterRenderer,
type LoadingPhrasesResolver,
@@ -619,6 +620,8 @@ export interface WebShellProps {
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
/** Custom renderer shown directly above the chat composer input. */
renderComposerHeader?: ComposerHeaderRenderer;
+ /** Custom renderer shown directly below the chat composer input. */
+ renderComposerFooter?: ComposerFooterRenderer;
/**
* Custom renderer shown at the top of the chat view, above the message list.
* Only rendered when a session is active (not in the welcome/empty state).
@@ -1111,6 +1114,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
+ renderComposerFooter,
renderChatHeader,
renderFooter,
bottomStatusItems,
@@ -1315,6 +1319,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
+ renderComposerFooter,
renderFooter,
compactThinking,
collapseCompletedTurns,
@@ -1337,6 +1342,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
+ renderComposerFooter,
renderFooter,
compactThinking,
collapseCompletedTurns,
@@ -1347,6 +1353,7 @@ export function App({
);
const CustomFooter = renderFooter;
const CustomComposerHeader = renderComposerHeader;
+ const CustomComposerFooter = renderComposerFooter;
const store = useTranscriptStore();
const blocks = useTranscriptBlocks();
const connection = useConnection();
@@ -8586,6 +8593,15 @@ export function App({
composerInputVersion={composerInputVersion}
placeholderText={composerPlaceholderText}
/>
+ {CustomComposerFooter && (
+
+ )}
{CustomFooter ? (
hasMobileComposerBottom ? (
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index e0084e70c31..6fb13be440d 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, forwardRef, useImperativeHandle } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
+import {
+ WebShellCustomizationProvider,
+ type WebShellComposerToolbarRenderInfo,
+ type WebShellCustomization,
+} from '../customization';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -136,7 +141,7 @@ vi.mock('./ChatEditor', () => ({
insertText,
}));
return (
-
+
);
diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx
index 27f6e4cc5f3..c79bfe1e555 100644
--- a/packages/web-shell/client/customization.tsx
+++ b/packages/web-shell/client/customization.tsx
@@ -318,6 +318,9 @@ export type ComposerToolbarRightRenderer =
export type ComposerHeaderRenderer =
ComponentType
;
+export type ComposerFooterRenderer =
+ ComponentType;
+
// ---- Background task info (public type for footer renderer) ----
interface WebShellTaskBase {
@@ -422,6 +425,7 @@ export interface WebShellCustomization {
renderComposerToolbarEnd?: ComposerToolbarEndRenderer;
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
renderComposerHeader?: ComposerHeaderRenderer;
+ renderComposerFooter?: ComposerFooterRenderer;
renderFooter?: FooterRenderer;
compactThinking?: boolean;
/**
diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx
index e8fff50b5e5..3a5f5cf01af 100644
--- a/packages/web-shell/client/index.tsx
+++ b/packages/web-shell/client/index.tsx
@@ -172,6 +172,7 @@ export type {
UserMessageContentRenderInfo,
UserMessageContentParser,
ComposerHeaderRenderer,
+ ComposerFooterRenderer,
ComposerToolbarStartRenderer,
ComposerToolbarRightRenderer,
WebShellAtItemRenderInfo,