Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* oxlint-disable react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop */
import type { GenerateModelConfig } from '@features/generation/core/types';
import type { WildcardRecord } from '@features/generation/data/wildcards';

import { ChakraProvider } from '@chakra-ui/react';
import { DndContext } from '@dnd-kit/core';
Expand All @@ -8,10 +9,11 @@ import { adjustFocusedPromptAttention } from '@features/generation/ui/promptFiel
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { system } from '@theme/system';
import i18next from 'i18next';
import { act } from 'react';
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';

const i18n = i18next.createInstance();

Expand All @@ -29,14 +31,19 @@ vi.mock('@features/generation/ui/GenerationUiContext', async (importOriginal) =>
}),
}));

const wildcardRecords: WildcardRecord[] = [];

vi.mock('@features/generation/data/wildcards', async (importOriginal) => ({
// Partial: the real module's other exports (constants, mappers) stay available to what imports them.
...(await importOriginal<Record<string, unknown>>()),
createWildcard: vi.fn(),
deleteWildcard: vi.fn(),
invalidateWildcardDependents: vi.fn(),
updateWildcard: vi.fn(),
wildcardsQueryOptions: () => ({ queryFn: () => Promise.resolve([]), queryKey: ['generation', 'wildcards'] }),
wildcardsQueryOptions: () => ({
queryFn: () => Promise.resolve(wildcardRecords),
queryKey: ['generation', 'wildcards'],
}),
}));

let host: HTMLDivElement | null = null;
Expand All @@ -48,6 +55,7 @@ afterEach(async () => {
host?.remove();
host = null;
root = null;
wildcardRecords.length = 0;
});

it('keeps positive prompt actions enabled when stale view mode has no active template', async () => {
Expand Down Expand Up @@ -148,3 +156,62 @@ it('colours and weights attention syntax only while the selected model reads it'
expect(await weightCat()).toBe(true);
await expect.poll(() => textarea().value).toBe('a cat++');
});

it('keeps overflowing wildcard suggestions open when the list scrolls', async () => {
wildcardRecords.push(
...Array.from({ length: 24 }, (_, index) => ({ id: `wildcard-${index}`, name: `w${index}`, values: ['red'] }))
);
const Prompt = () => {
const [value, setValue] = useState('');

return (
<PositivePromptField
heightPx={96}
loras={[]}
projectId="project-1"
selectedModel={undefined}
showSyntaxHighlighting={false}
value={value}
onChange={setValue}
onResizeEnd={vi.fn()}
onUsePrompt={vi.fn()}
/>
);
};

host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
await act(() => {
root?.render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<ChakraProvider value={system}>
<DndContext>
<Prompt />
</DndContext>
</ChakraProvider>
</QueryClientProvider>
</I18nextProvider>
);
});
await act(async () => {
await userEvent.type(host!.querySelector('textarea')!, '__');
});
await expect.poll(() => document.querySelector('[role="listbox"]')).not.toBeNull();

const list = document.querySelector<HTMLElement>('[role="listbox"]')!;

expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
await act(async () => {
const scrolled = new Promise<void>((resolve) => {
list.addEventListener('scroll', () => resolve(), { once: true });
});

list.scrollTop = list.scrollHeight;
await scrolled;
});

expect(document.querySelector('[role="listbox"]')).toBe(list);
expect(list.scrollTop).toBeGreaterThan(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { MouseEvent } from 'react';

import { Box, Portal, Stack, Text } from '@chakra-ui/react';
import { groupPromptTriggerOptions } from '@features/generation/ui/promptFields/promptTriggerOptions';
import { PROMPT_TRIGGER_AUTOCOMPLETE_SCROLL_PROPS } from '@features/generation/ui/promptFields/useDismissOnViewportChange';
import { MiddleTruncate } from '@platform/ui/MiddleTruncate';
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -70,6 +71,7 @@ export const PromptTriggerAutocomplete = ({
return (
<Portal>
<Box
{...PROMPT_TRIGGER_AUTOCOMPLETE_SCROLL_PROPS}
aria-label={t('widgets.generate.completionSuggestions')}
bg="bg.muted"
borderColor="border.emphasized"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { useMountEffect } from '@platform/react/useMountEffect';

/** Dismiss stale page-space anchors on scrolling, excluding the popover's own scroll. */
/**
* Marks the prompt-trigger autocomplete list, the scroll container for its options.
* Scrolling that list is the suggestion moving, not the anchored field leaving the viewport.
*/
const PROMPT_TRIGGER_AUTOCOMPLETE_ATTR = 'data-prompt-trigger-autocomplete';
export const PROMPT_TRIGGER_AUTOCOMPLETE_SCROLL_PROPS = { [PROMPT_TRIGGER_AUTOCOMPLETE_ATTR]: '' } as const;

/** Popover content and the autocomplete list. Descendants count: the event target may be an option. */
const EXEMPT_SCROLL_SELECTOR = `[data-scope="popover"][data-part="content"], [${PROMPT_TRIGGER_AUTOCOMPLETE_ATTR}]`;

/** Dismiss stale page-space anchors on scroll or resize, excluding scrolls inside an anchored surface. */
export const useDismissOnViewportChange = (enabled: boolean, dismiss: () => void): void => {
useMountEffect(() => {
if (!enabled) {
return;
}

const handleViewportChange = (event: Event) => {
if (event.target instanceof Element && event.target.closest('[data-scope="popover"][data-part="content"]')) {
// Resize targets the window, so it still dismisses. Only element scrolls can be internal.
if (event.target instanceof Element && event.target.closest(EXEMPT_SCROLL_SELECTOR)) {
return;
}

Expand Down
Loading