Skip to content
Merged
17 changes: 12 additions & 5 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -748,13 +748,16 @@
pointer-events: auto;
}

/* In-flow bottom sheet: the approval replaces the hidden composer, so the
message list shrinks above it instead of being covered by a floating
overlay. The relative positioning + z-index still keeps the sheet above
portal-hosted DialogShell modals (backdrop at z-50) without overlapping
the message list. */
.approvalOverlay {
position: absolute;
right: 20px;
bottom: calc(100% + 8px);
left: 20px;
position: relative;
z-index: calc(var(--web-shell-dialog-backdrop-z-index, 50) + 10);
pointer-events: auto;
margin-bottom: 8px;
min-width: 0;
}
Comment thread
ytahdn marked this conversation as resolved.

.approvalOverlay:focus,
Expand Down Expand Up @@ -827,6 +830,10 @@
padding: 0;
}

.composerHidden {
display: none;
}

.composerHeader {
margin-bottom: 8px;
}
Expand Down
49 changes: 49 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10397,6 +10397,55 @@ describe('App session callbacks', () => {
expect(testState.latestToolApprovalKeyboardActive).toBe(true);
});

it('hides the composer while a tool approval overlay is pending and restores it after resolution', async () => {
const { container, rerender } = renderApp();
await flush();

const composerWrapper = () =>
container.querySelector('[data-web-shell-composer]')?.parentElement;
expect(composerWrapper()?.className).not.toContain('composerHidden');

await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
expect(composerWrapper()?.className).toContain('composerHidden');

await act(async () => {
testState.blocks = [];
rerender();
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).toBeNull();
expect(composerWrapper()?.className).not.toContain('composerHidden');
});

it('hides the composer while an ask-user question overlay is pending', async () => {
const { container, rerender } = renderApp();
await flush();

await act(async () => {
testState.blocks = [
makePendingPermissionBlock({ toolName: 'ask_user_question' }),
];
rerender();
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
expect(
container.querySelector('[data-web-shell-composer]')?.parentElement
?.className,
).toContain('composerHidden');
});

it('does not show missing-session state for non-404/410 errors', async () => {
mockConnection.status = 'disconnected';
mockConnection.sessionId = undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12384,7 +12384,17 @@ export function App({
/>
</div>
)}
<div className={styles.composer}>
{/* A pending approval overlay owns the footer: drop the
composer out of layout (kept mounted so the draft
survives) instead of leaving a live input below the
dialog. */}
<div
className={
approvalOverlayActive && mainView === 'chat'
? `${styles.composer} ${styles.composerHidden}`
: styles.composer
}
>
{streamingState !== 'idle' ? (
suppressFailedPromptRetryStreaming ? null : (
<StreamingStatus
Expand Down
4 changes: 4 additions & 0 deletions packages/web-shell/client/components/ChatPane.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@
margin-bottom: 8px;
}

.composerHidden {
display: none;
}

.admissionUnknown {
display: flex;
align-items: center;
Expand Down
32 changes: 32 additions & 0 deletions packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,38 @@ describe('ChatPane', () => {
expect(footerProps.at(-1)?.disabled).toBe(true);
});

it('hides the pane composer while an approval is pending', () => {
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
render();
expect(testid('pane-approval')).not.toBeNull();
// The streaming status and the editor share the approval-hidden wrapper,
// so neither lingers below the dialog.
expect(testid('pane-streaming')?.parentElement?.className).toContain(
'composerHidden',
);
expect(
container!.querySelector('[data-web-shell-composer]')?.parentElement
?.className,
).toContain('composerHidden');
});

it('restores the pane composer after the approval resolves', () => {
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
render();
expect(
container!.querySelector('[data-web-shell-composer]')?.parentElement
?.className,
).toContain('composerHidden');

pendingPermission = null;
rerender();
expect(testid('pane-approval')).toBeNull();
expect(
container!.querySelector('[data-web-shell-composer]')?.parentElement
?.className,
).not.toContain('composerHidden');
});

it('adds no composer footer DOM when omitted or returning null', () => {
render();
const composer = container!.querySelector('[data-web-shell-composer]');
Expand Down
145 changes: 76 additions & 69 deletions packages/web-shell/client/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1043,79 +1043,86 @@ export function ChatPane({
/>
</div>
)}
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading phrase. */}
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
<QueuedPromptDisplay
prompts={queuedPrompts}
t={t}
canMutateMidTurn={canMutateMidTurn}
onDelete={removeQueuedPrompt}
onEdit={editQueuedPrompt}
onImagePreview={handleImagePreview}
/>
{unknownPromptAdmission && (
<div
className={styles.admissionUnknown}
role="status"
data-testid="pane-prompt-admission-unknown"
>
<span>{t('queue.admissionUnknown')}</span>
{unknownPromptAdmission.payloadAvailable && (
<span className={styles.admissionUnknownActions}>
<button type="button" onClick={continueEditingUnknownPrompt}>
{t('queue.continueEditing')}
</button>
<button type="button" onClick={discardUnknownPromptPayload}>
{t('queue.discardUnknown')}
</button>
</span>
)}
</div>
)}
<ChatEditor
ref={editorRef}
onSubmit={handleSubmit}
onCancel={handleCancel}
isRunning={isResponding}
commands={commands}
queuedMessages={queuedTexts}
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
visibleToolbarActions={paneToolbarActions}
workspaceName={showWorkspaceChip ? workspaceLabel : undefined}
workspaceTitle={paneWorkspaceCwd}
workspaceColor={workspaceAccent}
currentMode={connection.currentMode ?? 'default'}
sessionWorkflowEnabled={sessionWorkflowEnabled}
currentModel={connection.currentModel ?? ''}
availableModels={availableModels}
onSelectMode={handleSelectMode}
onSelectModel={handleSelectModel}
reasoning={connection.reasoning}
onSelectReasoningEffort={handleSelectReasoningEffort}
dialogOpen={approvalActive}
disabled={approvalActive || admissionPayloadLocked}
voiceTarget={hidden ? undefined : voiceTarget}
voiceStatusRevision={voiceStatusRevision}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
onImageIngestionNotice={onImageIngestionNotice}
sessionId={connection.sessionId}
onImagePreview={handleImagePreview}
atWorkspaceCwd={paneWorkspaceCwd}
placeholderText={t('splitView.composerPlaceholder')}
/>
{CustomComposerFooter && (
<CustomComposerFooter
disabled={approvalActive || admissionPayloadLocked}
{/* A pending approval owns the pane footer: the status/queue/editor
area below the approval drops out of layout (kept mounted so the
draft survives) instead of leaving a live input under the
dialog. */}
<div className={approvalActive ? styles.composerHidden : undefined}>
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading
phrase. */}
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
<QueuedPromptDisplay
prompts={queuedPrompts}
t={t}
canMutateMidTurn={canMutateMidTurn}
onDelete={removeQueuedPrompt}
onEdit={editQueuedPrompt}
onImagePreview={handleImagePreview}
/>
{unknownPromptAdmission && (
<div
className={styles.admissionUnknown}
role="status"
data-testid="pane-prompt-admission-unknown"
>
<span>{t('queue.admissionUnknown')}</span>
{unknownPromptAdmission.payloadAvailable && (
<span className={styles.admissionUnknownActions}>
<button type="button" onClick={continueEditingUnknownPrompt}>
{t('queue.continueEditing')}
</button>
<button type="button" onClick={discardUnknownPromptPayload}>
{t('queue.discardUnknown')}
</button>
</span>
)}
</div>
)}
<ChatEditor
ref={editorRef}
onSubmit={handleSubmit}
onCancel={handleCancel}
isRunning={isResponding}
commands={commands}
queuedMessages={queuedTexts}
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
visibleToolbarActions={paneToolbarActions}
workspaceName={showWorkspaceChip ? workspaceLabel : undefined}
workspaceTitle={paneWorkspaceCwd}
workspaceColor={workspaceAccent}
currentMode={connection.currentMode ?? 'default'}
sessionWorkflowEnabled={sessionWorkflowEnabled}
currentModel={connection.currentModel ?? ''}
sessionName={connection.displayName}
availableModels={availableModels}
onSelectMode={handleSelectMode}
onSelectModel={handleSelectModel}
reasoning={connection.reasoning}
onSelectReasoningEffort={handleSelectReasoningEffort}
dialogOpen={approvalActive}
disabled={approvalActive || admissionPayloadLocked}
voiceTarget={hidden ? undefined : voiceTarget}
voiceStatusRevision={voiceStatusRevision}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
onImageIngestionNotice={onImageIngestionNotice}
sessionId={connection.sessionId}
onImagePreview={handleImagePreview}
atWorkspaceCwd={paneWorkspaceCwd}
placeholderText={t('splitView.composerPlaceholder')}
/>
)}
{CustomComposerFooter && (
<CustomComposerFooter
disabled={approvalActive || admissionPayloadLocked}
isRunning={isResponding}
currentMode={connection.currentMode ?? 'default'}
currentModel={connection.currentModel ?? ''}
sessionName={connection.displayName}
/>
)}
</div>
</div>
</section>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
container-type: inline-size;
margin: 12px auto;
width: 100%;
max-width: min(800px, var(--chat-content-width, 800px));
max-width: min(100%, var(--chat-content-width, 1000px));
box-sizing: border-box;
padding: 16px;
border: 1.5px solid var(--border);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
display: flex;
width: 100%;
justify-content: flex-start;
margin: 12px 0;
}

.notificationBubbleColumn {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.approval {
margin: 0 auto;
width: 100%;
max-width: min(800px, var(--chat-content-width, 800px));
max-width: min(100%, var(--chat-content-width, 1000px));
box-sizing: border-box;
padding: 16px;
border: 1.5px solid var(--border);
Expand Down
Loading
Loading