diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index f99c2f46b5..27aa9c61da 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -238,13 +238,32 @@ export const useChatInputAreaStyles = makeStyles({ borderRadius: '4px', }, }, - convertedMediaPreview: { - maxHeight: '60px', + convertedFileBlock: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + convertedImagePreview: { + display: 'block', + maxHeight: '120px', maxWidth: '100%', borderRadius: tokens.borderRadiusSmall, objectFit: 'contain' as const, - flex: 1, - minWidth: 0, + alignSelf: 'flex-start', + }, + convertedAudioPreview: { + display: 'block', + width: '100%', + maxWidth: '360px', + height: '32px', + }, + convertedVideoPreview: { + display: 'block', + maxHeight: '160px', + maxWidth: '100%', + borderRadius: tokens.borderRadiusSmall, + alignSelf: 'flex-start', }, convertedFilename: { flex: 1, @@ -256,6 +275,24 @@ export const useChatInputAreaStyles = makeStyles({ fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, }, + openLink: { + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXXS, + padding: `0 ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusSmall, + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold as unknown as string, + textDecoration: 'none', + flexShrink: 0, + height: '20px', + ':hover': { + backgroundColor: tokens.colorBrandBackgroundHover, + color: tokens.colorNeutralForegroundOnBrand, + }, + }, unsupportedWarning: { display: 'flex', alignItems: 'center', diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index c4c893cc92..f7ddff1c41 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -558,7 +558,7 @@ describe("ChatInputArea", () => { ); @@ -586,7 +586,7 @@ describe("ChatInputArea", () => { @@ -663,6 +663,140 @@ describe("ChatInputArea", () => { expect(onClearConversion).toHaveBeenCalled(); }); + it("should render converted file chip with Open link for text→file conversion", async () => { + render( + + + + ); + + expect(screen.getByTestId("original-banner")).toBeInTheDocument(); + const chip = screen.getByTestId("converted-file-chip"); + expect(chip).toHaveTextContent("result.pdf"); + const openLink = screen.getByTestId("converted-file-open"); + expect(openLink).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.pdf"); + expect(openLink).toHaveAttribute("target", "_blank"); + }); + + it("should call onClearConvertedFileChip when chip dismiss is clicked", async () => { + const onClearConvertedFileChip = jest.fn(); + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByTestId("clear-converted-file-chip")); + expect(onClearConvertedFileChip).toHaveBeenCalledTimes(1); + }); + + it("should render an inline image preview for an image conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-image"); + expect(preview.tagName).toBe("IMG"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fresult.png"); + expect(preview).toHaveAttribute("alt", "result.png"); + // Header info (filename + Open link) is still rendered alongside the preview. + expect(screen.getByTestId("converted-file-chip")).toHaveTextContent("result.png"); + expect(screen.getByTestId("converted-file-open")).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.png"); + }); + + it("should render an inline audio preview for an audio conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-audio"); + expect(preview.tagName).toBe("AUDIO"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fspeech.wav"); + expect(preview).toHaveAttribute("controls"); + expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument(); + }); + + it("should render an inline video preview for a video conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-video"); + expect(preview.tagName).toBe("VIDEO"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fclip.mp4"); + expect(preview).toHaveAttribute("controls"); + }); + + it("should not render a media preview for a generic file conversion", () => { + render( + + + + ); + + expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-audio")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument(); + }); + // --------------------------------------------------------------------------- // Unsupported modality warnings // --------------------------------------------------------------------------- diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 82c1817e6d..43a97663fd 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -7,7 +7,7 @@ import { tokens, mergeClasses, } from '@fluentui/react-components' -import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular } from '@fluentui/react-icons' +import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' @@ -16,6 +16,12 @@ import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // Reusable status banner // --------------------------------------------------------------------------- +export interface ConvertedFileChip { + name: string + url: string + iconKind: 'image' | 'audio' | 'video' | 'file' +} + interface StatusBannerProps { icon: React.ReactElement text: string @@ -55,7 +61,7 @@ function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testI interface AttachmentListProps { attachments: MessageAttachment[] - mediaConversions: Array<{ pieceType: string; convertedValue: string }> + mediaConversions: Array<{ pieceType: string; convertedValue: string; convertedDataType: string }> onRemove: (index: number) => void onClearMediaConversion: (pieceType: string) => void formatFileSize: (bytes: number) => string @@ -120,21 +126,24 @@ function AttachmentList({ attachments, mediaConversions, onRemove, onClearMediaC interface TextInputRowsProps { input: string convertedValue?: string | null + convertedFileChip?: ConvertedFileChip | null disabled: boolean textareaRef: Ref convertedRef: Ref onInput: (e: React.ChangeEvent) => void onKeyDown: (e: KeyboardEvent) => void onConvertedValueChange: (value: string) => void + onClearConvertedFileChip?: () => void styles: ReturnType textInputClassName: string } -function TextInputRows({ input, convertedValue, disabled, textareaRef, convertedRef, onInput, onKeyDown, onConvertedValueChange, styles, textInputClassName }: TextInputRowsProps) { +function TextInputRows({ input, convertedValue, convertedFileChip, disabled, textareaRef, convertedRef, onInput, onKeyDown, onConvertedValueChange, onClearConvertedFileChip, styles, textInputClassName }: TextInputRowsProps) { + const hasConversion = Boolean(convertedValue) || Boolean(convertedFileChip) return ( <>
- {convertedValue && ( + {hasConversion && ( Original )}