feat(chat): auto-resize composer textarea to fit content (#3139)

This commit is contained in:
Cyrus Gray
2026-06-01 20:16:14 +05:30
committed by GitHub
parent e9c69a42fb
commit b04e1f8729
2 changed files with 33 additions and 16 deletions
@@ -1,5 +1,4 @@
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -7,9 +6,13 @@ import {
getNotificationPermissionState,
showNativeNotification,
} from '../../lib/nativeNotifications/tauriBridge';
import { isTauri } from '../../services/webviewAccountService';
import OpenhumanLinkModal, { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal';
vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn() }));
vi.mock('../../services/webviewAccountService', () => ({
isTauri: vi.fn(() => false),
purgeWebviewAccount: vi.fn(),
}));
vi.mock('../../lib/nativeNotifications/tauriBridge', () => ({
ensureNotificationPermission: vi.fn(),
@@ -38,7 +41,7 @@ describe('OpenhumanLinkModal notifications test flow', () => {
}
it('shows success after permission is granted and native notification send succeeds', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(true);
vi.mocked(showNativeNotification).mockResolvedValue({ delivered: true });
@@ -46,18 +49,19 @@ describe('OpenhumanLinkModal notifications test flow', () => {
openNotificationsModal();
fireEvent.click(screen.getByRole('button', { name: 'Send test notification' }));
await flushAsyncWork();
expect(
screen.getByText(/Test notification sent\. If you didn't receive it/i)
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(/Test notification sent\. If you didn't receive it/i)
).toBeInTheDocument();
});
expect(showNativeNotification).toHaveBeenCalledWith(
expect.objectContaining({ tag: 'welcome-notification-test' })
);
});
it('shows actionable error when permission is denied and does not send notification', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(false);
vi.mocked(getNotificationPermissionState).mockResolvedValue('denied');
@@ -73,7 +77,7 @@ describe('OpenhumanLinkModal notifications test flow', () => {
});
it('shows send failure message when native notification call fails', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(true);
vi.mocked(showNativeNotification).mockResolvedValue({
delivered: false,
@@ -93,7 +97,7 @@ describe('OpenhumanLinkModal notifications test flow', () => {
});
it('retries successfully after user grants permission on a second attempt', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(isTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
@@ -112,11 +116,12 @@ describe('OpenhumanLinkModal notifications test flow', () => {
expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry test notification' }));
await flushAsyncWork();
expect(
screen.getByText(/Test notification sent\. If you didn't receive it/i)
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(/Test notification sent\. If you didn't receive it/i)
).toBeInTheDocument();
});
expect(showNativeNotification).toHaveBeenCalledTimes(1);
});
});
+13 -1
View File
@@ -288,6 +288,8 @@ const Conversations = ({
}, [agentProfiles, profileDraft.agentId, t]);
const textInputRef = useRef<HTMLTextAreaElement>(null);
/** Max composer height ≈ 4 lines of text-sm + padding. */
const COMPOSER_MAX_HEIGHT = 96;
const isComposingTextRef = useRef(false);
const pendingSendRef = useRef<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@@ -1138,6 +1140,16 @@ const Conversations = ({
});
return () => window.cancelAnimationFrame(id);
}, [selectedThreadId, composerInteractionBlocked, inputMode]);
// Auto-resize composer textarea: grow with content, cap at COMPOSER_MAX_HEIGHT, then scroll.
useEffect(() => {
const ta = textInputRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = `${Math.min(ta.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
ta.style.overflowY = ta.scrollHeight > COMPOSER_MAX_HEIGHT ? 'auto' : 'hidden';
}, [inputValue]);
const isSending = Boolean(
selectedThreadId &&
(pendingSendingThreadId === selectedThreadId ||
@@ -2189,7 +2201,7 @@ const Conversations = ({
placeholder={t('chat.typeMessage')}
rows={1}
disabled={composerInteractionBlocked || isSending}
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
/>
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
</div>