Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <div data-testid="composer-footer">composer footer</div>;
};
const { container } = renderApp({
renderComposerHeader: () => (
<div data-testid="composer-header">composer header</div>
),
renderComposerFooter: ComposerFooter,
renderFooter: () => <div data-testid="shell-footer">shell footer</div>,
});
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 <div data-testid="composer-footer" />;
};
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;
Expand Down
16 changes: 16 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ import {
type ComposerToolbarEndRenderer,
type ComposerToolbarRightRenderer,
type ComposerHeaderRenderer,
type ComposerFooterRenderer,
type ChatHeaderRenderer,
type FooterRenderer,
type LoadingPhrasesResolver,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -1111,6 +1114,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
renderComposerFooter,
renderChatHeader,
renderFooter,
bottomStatusItems,
Expand Down Expand Up @@ -1315,6 +1319,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
renderComposerFooter,
renderFooter,
compactThinking,
collapseCompletedTurns,
Expand All @@ -1337,6 +1342,7 @@ export function App({
renderComposerToolbarEnd,
renderComposerToolbarRight,
renderComposerHeader,
renderComposerFooter,
renderFooter,
compactThinking,
collapseCompletedTurns,
Expand All @@ -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();
Expand Down Expand Up @@ -8586,6 +8593,15 @@ export function App({
composerInputVersion={composerInputVersion}
placeholderText={composerPlaceholderText}
/>
{CustomComposerFooter && (
<CustomComposerFooter
disabled={isDisabled}
isRunning={streamingState !== 'idle'}
Comment on lines +8596 to +8599

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] CustomComposerFooter is rendered without a shell-owned wrapper div, while CustomComposerHeader (line 8459) is wrapped in <div className={styles.composerHeader}> which provides margin-bottom: 8px. This creates a spacing asymmetry — hosts providing both renderers see 8px above the composer (shell-provided) but 0px below it. — Concrete cost: every host consumer must independently discover and add margin-top to their footer renderer to achieve symmetric spacing.

Suggested change
{CustomComposerFooter && (
<CustomComposerFooter
disabled={isDisabled}
isRunning={streamingState !== 'idle'}
{CustomComposerFooter && (
<div className={styles.composerFooter}>
<CustomComposerFooter
disabled={isDisabled}
isRunning={streamingState !== 'idle'}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionDisplayName}
/>
</div>
)}
中文说明

[Suggestion] CustomComposerFooter 渲染时没有 shell 提供的包裹 div,而 CustomComposerHeader(第 8459 行)被包裹在 <div className={styles.composerHeader}> 中(提供 margin-bottom: 8px)。这导致间距不对称——同时提供两个渲染器的宿主会看到输入框上方有 8px 间距(shell 提供),但下方为 0px。—— 具体成本:每个宿主消费者都需要自行发现并在 footer 渲染器中添加 margin-top 以实现对称间距。

— qwen3.7-max via Qwen Code /review

currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionDisplayName}
/>
)}
</div>
{CustomFooter ? (
hasMobileComposerBottom ? (
Expand Down
Loading
Loading