diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index b39e1db101..4960217ef5 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.39.1", + "version": "7.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.39.1", + "version": "7.40.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 19c7ba1c93..44b6b4b2c1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.39.1", + "version": "7.40.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index dd13b772d3..4aeffd5135 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,8 +1,16 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version TBD -*Released*: TBD +### version 7.40.0 +*Released*: 28 May 2026 +- Calculated Column Assistant + - Keep the modal open after clicking apply expression + - Send the current field to server to distinguish from a field set + - Display column type for validated expressions + - Refill prompt when request interrupted + +### version 7.39.0 +*Released*: 27 May 2026 - Misc. accessibility improvements - Auto-link to study input field labels - Remove tabIndex value from `DomainRow` diff --git a/packages/components/src/internal/components/domainproperties/CalculatedFieldOptions.tsx b/packages/components/src/internal/components/domainproperties/CalculatedFieldOptions.tsx index 1b04cdf8c5..b8f908aab2 100644 --- a/packages/components/src/internal/components/domainproperties/CalculatedFieldOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/CalculatedFieldOptions.tsx @@ -163,10 +163,9 @@ export const CalculatedFieldOptions: FC = memo(prop setError(undefined); setParsedType(undefined); validateExpression(analysis, true); - close(); incrementClientSideMetricCount(EXPR_ASST_METRIC_FEATURE_AREA, 'applyExpression'); }, - [close, inputId, onChange, validateExpression] + [inputId, onChange, validateExpression] ); const onOpenAssistant = useCallback(() => { @@ -293,12 +292,12 @@ export const CalculatedFieldOptions: FC = memo(prop {show && ( )} diff --git a/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.test.tsx b/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.test.tsx index 35a26e3661..e2ffa53bcc 100644 --- a/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.test.tsx +++ b/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.test.tsx @@ -46,7 +46,13 @@ function makeField(name: string, rangeURI = 'http://www.w3.org/2001/XMLSchema#st return DomainField.create({ name, rangeURI, PHI: phi }); } -const DEFAULT_FIELDS = [makeField('A'), makeField('B', 'http://www.w3.org/2001/XMLSchema#int')]; +function makeCalculatedField(valueExpression?: string, name = 'CALC_FIELD'): DomainField { + return DomainField.create({ name, rangeURI: 'http://www.w3.org/2001/XMLSchema#calculated', valueExpression }); +} + +const CALC_FIELD = makeCalculatedField('SELECT 1'); + +const DEFAULT_FIELDS = [makeField('A'), makeField('B', 'http://www.w3.org/2001/XMLSchema#int'), CALC_FIELD]; function getDomainFields(fields = DEFAULT_FIELDS) { return () => ({ domainFields: List(fields), @@ -56,6 +62,7 @@ function getDomainFields(fields = DEFAULT_FIELDS) { function defaultProps(overrides?: Partial): ExpressionAssistantModalProps { return { + field: CALC_FIELD, getDomainFields: getDomainFields(), onCancel: jest.fn(), ...overrides, @@ -86,7 +93,10 @@ describe('ExpressionAssistantModal', () => { const expressionAssistant = jest.fn(); // Act - renderWithAppContext(, makeApiContext(expressionAssistant)); + renderWithAppContext( + , + makeApiContext(expressionAssistant) + ); // Assert - one assistant intro message with the NEW prompt text and no SQL segment const messages = chatModalProps.messages as ChatMessage[]; @@ -99,10 +109,7 @@ describe('ExpressionAssistantModal', () => { test('shows the CHANGE intro with a SQL segment when fieldExpression is provided', () => { // Arrange / Act - renderWithAppContext( - , - makeApiContext() - ); + renderWithAppContext(, makeApiContext()); // Assert - intro begins with the CHANGE prompt and includes a sql segment containing the existing expression const intro = (chatModalProps.messages as ChatMessage[])[0]; @@ -120,7 +127,11 @@ describe('ExpressionAssistantModal', () => { // Act renderWithAppContext( - , + , makeApiContext(expressionAssistant) ); @@ -188,12 +199,17 @@ describe('ExpressionAssistantModal', () => { test('passes columnMap and PHI columns derived from the provided domain fields', async () => { // Arrange const fields = [ + CALC_FIELD, makeField('plain', 'http://www.w3.org/2001/XMLSchema#string'), makeField('secret', 'http://www.w3.org/2001/XMLSchema#string', 'Restricted'), ]; const expressionAssistant = jest.fn().mockResolvedValue({ conversationId: 'c', success: true, text: 'ok' }); renderWithAppContext( - , + , makeApiContext(expressionAssistant) ); @@ -308,11 +324,11 @@ describe('ExpressionAssistantModal', () => { }); describe('renderSegment / SqlExpression', () => { - test('expression segments render an Apply Expression action that calls onComplete with the SQL', () => { + test('expression segments render an Apply Expression action that calls onApplyExpression with the SQL', () => { // Arrange - const onComplete = jest.fn(); + const onApplyExpression = jest.fn(); renderWithAppContext( - , + , makeApiContext() ); // Render the segment ourselves into a container so we can interact with it @@ -322,17 +338,17 @@ describe('ExpressionAssistantModal', () => { const { unmount } = render(
{node}
); fireEvent.click(screen.getByRole('button', { name: /apply expression/i })); - // Assert - the SQL is shown and clicking Apply forwards the expression to onComplete + // Assert - the SQL is shown, and clicking Apply forwards the expression to onApplyExpression expect(screen.getByText('SELECT 1')).toBeInTheDocument(); - expect(onComplete).toHaveBeenCalledTimes(1); - expect(onComplete).toHaveBeenCalledWith('SELECT 1'); + expect(onApplyExpression).toHaveBeenCalledTimes(1); + expect(onApplyExpression).toHaveBeenCalledWith('SELECT 1'); unmount(); }); test('sql segments render read-only without an Apply action', () => { // Arrange renderWithAppContext( - , + , makeApiContext() ); const node = chatModalProps.renderSegment({ type: 'sql', sql: 'SELECT 2' }, 0); @@ -345,15 +361,15 @@ describe('ExpressionAssistantModal', () => { expect(screen.queryByRole('button', { name: /apply expression/i })).not.toBeInTheDocument(); }); - test('expression segment without onComplete still renders read-only', () => { - // Arrange - omit onComplete + test('expression segment without onApplyExpression still renders read-only', () => { + // Arrange - omit onApplyExpression renderWithAppContext(, makeApiContext()); const node = chatModalProps.renderSegment({ type: 'expression', sql: 'SELECT 3' }, 0); // Act render(
{node}
); - // Assert - SQL still renders, but there is no Apply action when no onComplete is supplied + // Assert - SQL still renders, but there is no Apply action when no onApplyExpression is supplied expect(screen.getByText('SELECT 3')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /apply expression/i })).not.toBeInTheDocument(); }); diff --git a/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.tsx b/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.tsx index a32dde317e..7ec4db8b04 100644 --- a/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.tsx +++ b/packages/components/src/internal/components/domainproperties/ExpressionAssistantModal.tsx @@ -1,4 +1,5 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; import { useAppContext } from '../../AppContext'; import { generateId } from '../../util/utils'; import { ChatModal, RenderSegment } from '../mcp/ChatModal'; @@ -6,7 +7,7 @@ import { ChatMessage, ChatRole, ChatSegment } from '../mcp/models'; import { DomainField, GetDomainFields, SystemField } from './models'; import { useRequestHandler } from '../../util/RequestHandler'; import { incrementClientSideMetricCount } from '../../actions'; -import { getColumnTypeMap, getPHIColumnNames } from './CalculatedFieldOptions'; +import { getColumnTypeMap, getPHIColumnNames, typeToDisplay } from './CalculatedFieldOptions'; import { ExpressionAssistOptions } from './actions'; import { resolveErrorMessage } from '../../util/messaging'; @@ -26,22 +27,43 @@ function createChatMessage(message: Partial): ChatMessage { } interface SqlSnippetProps { - onApply?: (sql: string) => void; + jdbcType?: string; + onApplyExpression?: (sql: string) => void; readOnly?: boolean; sql: string; } -const SqlExpression: FC = memo(({ onApply, readOnly, sql }) => { - const handleApply = useCallback(() => onApply?.(sql), [onApply, sql]); +const SqlExpression: FC = memo(({ onApplyExpression, jdbcType, readOnly, sql }) => { + const [animating, setAnimating] = useState(false); + const handleApply = useCallback(() => { + setAnimating(true); + onApplyExpression(sql); + }, [onApplyExpression, sql]); + + const onAnimationEnd = useCallback(() => { + setAnimating(false); + }, []); + return (
                 {sql}
             
- {!readOnly && onApply && ( - + {!readOnly && onApplyExpression && ( + <> + + {jdbcType && ( + + The calculated data type is {typeToDisplay(jdbcType).toLowerCase()} + + )} + )}
); @@ -49,17 +71,17 @@ const SqlExpression: FC = memo(({ onApply, readOnly, sql }) => SqlExpression.displayName = 'SqlExpression'; export interface ExpressionAssistantModalProps { + field: DomainField; fieldError?: string; - fieldExpression?: string; getDomainFields: GetDomainFields; + onApplyExpression?: (analysis: string) => void; onCancel: () => void; - onComplete?: (analysis: string) => void; } function useExpressionAssistance( domainFields: DomainField[], systemFields: SystemField[], - fieldExpression?: string, + field: DomainField, fieldError?: string ) { const [conversationId, setConversationId] = useState(); @@ -68,9 +90,9 @@ function useExpressionAssistance( let segments: ChatSegment[] | undefined; if (fieldError) { text = VALIDATE_INTRO; - } else if (fieldExpression) { + } else if (field.valueExpression) { text = CHANGE_INTRO; - segments = [{ type: 'sql', sql: fieldExpression }]; + segments = [{ type: 'sql', sql: field.valueExpression }]; } else { text = NEW_INTRO; } @@ -122,8 +144,9 @@ function useExpressionAssistance( if (conversationId === undefined) { options.domainFields = combinedFields; + options.field = field; options.fieldError = fieldError; - options.fieldExpression = fieldExpression; + options.fieldExpression = field.valueExpression; } const response = await api.domain.expressionAssistant(options); @@ -160,8 +183,8 @@ function useExpressionAssistance( columnMap, combinedFields, conversationId, + field, fieldError, - fieldExpression, phiColumns, pushMessage, requestHandler, @@ -187,7 +210,7 @@ function useExpressionAssistance( } export const ExpressionAssistantModal: FC = memo(props => { - const { fieldError, fieldExpression, getDomainFields, onCancel, onComplete } = props; + const { field, fieldError, getDomainFields, onApplyExpression, onCancel } = props; const { domainFields, systemFields } = useMemo(() => { const { domainFields, systemFields } = getDomainFields(); return { domainFields: domainFields.toArray(), systemFields }; @@ -195,21 +218,28 @@ export const ExpressionAssistantModal: FC = memo( const { isPending, messages, onInterrupt, sendPrompt } = useExpressionAssistance( domainFields, systemFields, - fieldExpression, + field, fieldError ); const renderSegment = useCallback( (segment, index) => { if (segment.type === 'expression' && segment.sql) { - return ; + return ( + + ); } if (segment.type === 'sql' && segment.sql) { return ; } return undefined; }, - [onComplete] + [onApplyExpression] ); return ( diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index b3efac29d7..4546a71ee2 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -1532,6 +1532,7 @@ export interface ExpressionAssistOptions { containerPath?: string; conversationId?: string; domainFields?: (DomainField | SystemField)[]; + field?: DomainField; fieldError?: string; fieldExpression?: string; phiColumns?: string[]; @@ -1554,11 +1555,22 @@ export interface ExpressionAssistResponse { } export function expressionAssistant(options: ExpressionAssistOptions): Promise { - const { containerPath, requestHandler, ...jsonData } = options; + const { containerPath, domainFields, field, requestHandler, ...jsonData } = options; + + const serializedField = field ? DomainField.serialize(field) : undefined; + if (serializedField) { + // Do not pass the value expression for the current field as that is supplied separately + delete serializedField.valueExpression; + } + return request({ url: ActionURL.buildURL('query', 'expressionAssistantAgent.api', containerPath), method: 'POST', - jsonData, + jsonData: { + ...jsonData, + domainFields: domainFields?.map(f => (f instanceof DomainField ? DomainField.serialize(f) : f)), + field: serializedField, + }, errorLogMsg: 'Failed to assist with expression', requestHandler, }); diff --git a/packages/components/src/internal/components/mcp/ChatModal.test.tsx b/packages/components/src/internal/components/mcp/ChatModal.test.tsx index c9ac51ee6b..5a1bac426e 100644 --- a/packages/components/src/internal/components/mcp/ChatModal.test.tsx +++ b/packages/components/src/internal/components/mcp/ChatModal.test.tsx @@ -221,6 +221,51 @@ describe('ChatModal', () => { }); }); + describe('interrupt behavior', () => { + test('stop refills the prompt with the last sent value when the input is empty', () => { + // Arrange + const sendPrompt = jest.fn().mockResolvedValue(undefined); + const onInterrupt = jest.fn(); + const { rerender } = render(); + const textarea = getTextarea(); + + // Act - send a prompt, then simulate pending state, then click stop + fireEvent.change(textarea, { target: { value: 'first prompt' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + expect(sendPrompt).toHaveBeenCalledWith('first prompt'); + expect(getTextarea()).toHaveValue(''); + + rerender(); + fireEvent.click(document.querySelector('.prompt-button') as HTMLButtonElement); + + // Assert - interrupt fired and the empty input is repopulated with the last sent value + expect(onInterrupt).toHaveBeenCalledWith(true); + rerender(); + expect(getTextarea()).toHaveValue('first prompt'); + }); + + test('stop preserves the current input when the user has typed something else', () => { + // Arrange + const sendPrompt = jest.fn().mockResolvedValue(undefined); + const onInterrupt = jest.fn(); + const { rerender } = render(); + const textarea = getTextarea(); + + // Act - send a prompt, transition to pending, then type a new prompt before clicking stop + fireEvent.change(textarea, { target: { value: 'first prompt' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + rerender(); + fireEvent.change(getTextarea(), { target: { value: 'new draft' } }); + fireEvent.click(document.querySelector('.prompt-button') as HTMLButtonElement); + + // Assert - interrupt fired and the user's in-progress text is not overwritten + expect(onInterrupt).toHaveBeenCalledWith(true); + rerender(); + expect(getTextarea()).toHaveValue('new draft'); + }); + }); + describe('cancel behavior', () => { test('End Chat calls onInterrupt(false) then onCancel', () => { // Arrange diff --git a/packages/components/src/internal/components/mcp/ChatModal.tsx b/packages/components/src/internal/components/mcp/ChatModal.tsx index 5b2cf913a4..973f0f1895 100644 --- a/packages/components/src/internal/components/mcp/ChatModal.tsx +++ b/packages/components/src/internal/components/mcp/ChatModal.tsx @@ -60,6 +60,7 @@ export interface ChatModalProps { export const ChatModal: FC = memo(props => { const { isPending, messages, onCancel, onInterrupt, renderSegment, sendPrompt, title } = props; const [prompt, setPrompt] = useState(''); + const lastSentPromptRef = useRef(''); const textAreaRef = useRef(null); const historyRef = useRef(null); const timer = useTimeout(); @@ -104,11 +105,22 @@ export const ChatModal: FC = memo(props => { const handleInterrupt = useCallback(() => { onInterrupt(true); - }, [onInterrupt]); + setPrompt(current => (current === '' ? lastSentPromptRef.current : current)); + + // Place the cursor back where it was in the prompt + timer.set(() => { + const el = textAreaRef.current; + if (!el) return; + el.focus(); + const end = el.value.length; + el.setSelectionRange(end, end); + }); + }, [onInterrupt, timer]); const handleSend = useCallback(() => { const trimmed = prompt.trim(); if (!trimmed || isPending) return; + lastSentPromptRef.current = trimmed; setPrompt(''); sendPrompt(trimmed); }, [prompt, isPending, sendPrompt]); diff --git a/packages/components/src/internal/components/mcp/models.ts b/packages/components/src/internal/components/mcp/models.ts index 9558fedc02..76a77894e6 100644 --- a/packages/components/src/internal/components/mcp/models.ts +++ b/packages/components/src/internal/components/mcp/models.ts @@ -9,6 +9,7 @@ export enum ChatRole { // (e.g., an applicable SQL expression). export interface ChatSegment { html?: string; + jdbcType?: string; sql?: string; text?: string; type: string; diff --git a/packages/components/src/theme/chat.scss b/packages/components/src/theme/chat.scss index 22ea843341..05138882a4 100644 --- a/packages/components/src/theme/chat.scss +++ b/packages/components/src/theme/chat.scss @@ -66,6 +66,10 @@ margin-bottom: 8px; } + .assistant-expression__type { + float: right; + } + .error-response { color: $brand-danger; } @@ -124,3 +128,21 @@ margin-top: 12px; } } + +@keyframes customBounce { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.4); + color: $brand-success; + } + 100% { + transform: scale(1); + } +} + +.bounce-effect { + display: inline-block; + animation: customBounce 0.6s ease-in-out; +}