mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
feat(chat): auto-resize composer textarea to fit content (#3139)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user