From b04e1f87290fce6ffd7a7467a581ac9d1e638ef3 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:16:14 +0530 Subject: [PATCH] feat(chat): auto-resize composer textarea to fit content (#3139) --- .../OpenhumanLinkModal.notifications.test.tsx | 35 +++++++++++-------- app/src/pages/Conversations.tsx | 14 +++++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx index bcbb85196..f552298e4 100644 --- a/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx +++ b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx @@ -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); }); }); diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index bf9e195f4..00f136da9 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -288,6 +288,8 @@ const Conversations = ({ }, [agentProfiles, profileDraft.agentId, t]); const textInputRef = useRef(null); + /** Max composer height ≈ 4 lines of text-sm + padding. */ + const COMPOSER_MAX_HEIGHT = 96; const isComposingTextRef = useRef(false); const pendingSendRef = useRef(null); const mediaRecorderRef = useRef(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). */}