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
45 changes: 41 additions & 4 deletions frontend/src/components/Chat/ChatInputArea.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down
138 changes: 136 additions & 2 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ describe("ChatInputArea", () => {
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png" }]}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png", convertedDataType: "image_path" }]}
/>
</TestWrapper>
);
Expand Down Expand Up @@ -586,7 +586,7 @@ describe("ChatInputArea", () => {
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png" }]}
mediaConversions={[{ pieceType: "image", convertedValue: "/tmp/converted.png", convertedDataType: "image_path" }]}
onClearMediaConversion={onClearMediaConversion}
/>
</TestWrapper>
Expand Down Expand Up @@ -663,6 +663,140 @@ describe("ChatInputArea", () => {
expect(onClearConversion).toHaveBeenCalled();
});

it("should render converted file chip with Open link for text→file conversion", async () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
onClearConvertedFileChip={onClearConvertedFileChip}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.png",
url: "/api/media?path=%2Ftmp%2Fresult.png",
iconKind: "image",
}}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "speech.wav",
url: "/api/media?path=%2Ftmp%2Fspeech.wav",
iconKind: "audio",
}}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "clip.mp4",
url: "/api/media?path=%2Ftmp%2Fclip.mp4",
iconKind: "video",
}}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
activeTarget={{ target_registry_name: "t", target_type: "T", endpoint: "e", model_name: "m" }}
convertedFileChip={{
name: "result.pdf",
url: "/api/media?path=%2Ftmp%2Fresult.pdf",
iconKind: "file",
}}
/>
</TestWrapper>
);

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
// ---------------------------------------------------------------------------
Expand Down
86 changes: 80 additions & 6 deletions frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -120,21 +126,24 @@ function AttachmentList({ attachments, mediaConversions, onRemove, onClearMediaC
interface TextInputRowsProps {
input: string
convertedValue?: string | null
convertedFileChip?: ConvertedFileChip | null
disabled: boolean
textareaRef: Ref<HTMLTextAreaElement>
convertedRef: Ref<HTMLTextAreaElement>
onInput: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onConvertedValueChange: (value: string) => void
onClearConvertedFileChip?: () => void
styles: ReturnType<typeof useChatInputAreaStyles>
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 (
<>
<div className={styles.textRow}>
{convertedValue && (
{hasConversion && (
<span className={styles.originalBadge} data-testid="original-banner">Original</span>
)}
<textarea
Expand Down Expand Up @@ -162,6 +171,66 @@ function TextInputRows({ input, convertedValue, disabled, textareaRef, converted
/>
</div>
)}
{!convertedValue && convertedFileChip && (
<div className={styles.convertedFileBlock} data-testid="converted-file-chip">
<div className={styles.convertedRow}>
<span className={styles.convertedBadge}>Converted</span>
<span aria-hidden="true">
{convertedFileChip.iconKind === 'image' && '🖼️'}
{convertedFileChip.iconKind === 'audio' && '🎵'}
{convertedFileChip.iconKind === 'video' && '🎥'}
{convertedFileChip.iconKind === 'file' && '📄'}
</span>
<Caption1 className={styles.convertedFilename} title={convertedFileChip.name}>
{convertedFileChip.name}
</Caption1>
<Tooltip content="Open in new tab" relationship="label">
<a
href={convertedFileChip.url}
target="_blank"
rel="noopener noreferrer"
className={styles.openLink}
data-testid="converted-file-open"
>
<OpenRegular fontSize={14} />
<span>Open</span>
</a>
</Tooltip>
<Button
appearance="transparent"
size="small"
className={styles.dismissBtn}
icon={<DismissRegular />}
onClick={onClearConvertedFileChip}
data-testid="clear-converted-file-chip"
/>
</div>
{convertedFileChip.iconKind === 'image' && (
<img
src={convertedFileChip.url}
alt={convertedFileChip.name}
className={styles.convertedImagePreview}
data-testid="converted-file-preview-image"
/>
)}
{convertedFileChip.iconKind === 'audio' && (
<audio
controls
src={convertedFileChip.url}
className={styles.convertedAudioPreview}
data-testid="converted-file-preview-audio"
/>
)}
{convertedFileChip.iconKind === 'video' && (
<video
controls
src={convertedFileChip.url}
className={styles.convertedVideoPreview}
data-testid="converted-file-preview-video"
/>
)}
</div>
)}
</>
)
}
Expand Down Expand Up @@ -255,11 +324,14 @@ interface ChatInputAreaProps {
onClearConversion: () => void
onConvertedValueChange: (value: string) => void
converterOutputDataTypes?: string[]
mediaConversions?: Array<{ pieceType: string; convertedValue: string }>
mediaConversions?: Array<{ pieceType: string; convertedValue: string; convertedDataType: string }>
onClearMediaConversion: (pieceType: string) => void
/** Chip describing a text→file conversion (e.g. PDFConverter output). */
convertedFileChip?: ConvertedFileChip | null
onClearConvertedFileChip?: () => void
}

const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion }, ref) {
const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip }, ref) {
const styles = useChatInputAreaStyles()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<MessageAttachment[]>([])
Expand Down Expand Up @@ -526,12 +598,14 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
<TextInputRows
input={input}
convertedValue={convertedValue}
convertedFileChip={convertedFileChip}
disabled={disabled}
textareaRef={textareaRef}
convertedRef={convertedRef}
onInput={handleInput}
onKeyDown={handleKeyDown}
onConvertedValueChange={onConvertedValueChange}
onClearConvertedFileChip={onClearConvertedFileChip}
styles={styles}
textInputClassName={textInputClassName}
/>
Expand Down
Loading
Loading