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 ( -
+