From 4e8d6c1f42e8ffc2063f14f5087e290a3df7ecae Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 14:19:49 -0700 Subject: [PATCH 01/10] FEAT: Warn GUI users when target doesn't support attachment modality Expose supported_input_data_types from TargetCapabilities through the backend DTO and mapper so the frontend knows which input modalities a target accepts. ChatInputArea now shows a non-blocking warning when the user attaches a file whose type (image, audio, video, file) is not in the target's supported input data types. Changes: - Add supported_input_data_types field to TargetInstance backend model - Flatten input_modalities in target_object_to_instance mapper - Add supported_input_data_types to frontend TargetInstance type - Add file->binary_path mapping in converterTypes - Derive unsupported attachment types in ChatInputArea and show warning - Add backend mapper tests and frontend component tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/package-lock.json | 4 +- .../components/Chat/ChatInputArea.styles.ts | 7 + .../components/Chat/ChatInputArea.test.tsx | 188 ++++++++++++++++++ .../src/components/Chat/ChatInputArea.tsx | 35 ++++ .../src/components/Chat/converterTypes.ts | 1 + frontend/src/types/index.ts | 1 + pyrit/backend/mappers/target_mappers.py | 3 + pyrit/backend/models/targets.py | 4 + tests/unit/backend/test_mappers.py | 49 +++++ 9 files changed, 290 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4f9d85124..a6e8f34487 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pyrit-frontend", - "version": "0.13.0-dev.0", + "version": "0.14.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pyrit-frontend", - "version": "0.13.0-dev.0", + "version": "0.14.0-dev.0", "dependencies": { "@azure/msal-browser": "^5.5.0", "@azure/msal-react": "^5.0.7", diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index b53e720e58..378b07f6b0 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -228,6 +228,13 @@ export const useChatInputAreaStyles = makeStyles({ fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, }, + unsupportedWarning: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXXS, + color: tokens.colorPaletteYellowForeground2, + fontSize: tokens.fontSizeBase200, + }, originalBadge: { display: 'inline-block', padding: `0 ${tokens.spacingHorizontalXS}`, diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index 570fb93039..737481cf7c 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -647,4 +647,192 @@ describe("ChatInputArea", () => { expect(onSend).toHaveBeenCalledWith("hello", "convertedHello", []); expect(onClearConversion).toHaveBeenCalled(); }); + + // --------------------------------------------------------------------------- + // Unsupported modality warnings + // --------------------------------------------------------------------------- + + it("should show warning when attaching image to text-only target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/may not support image files/)).toBeInTheDocument(); + }); + }); + + it("should not show warning when attaching image to image-capable target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByText(/photo\.png/)).toBeInTheDocument(); + }); + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); + + it("should not show warning when no target is selected", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByText(/photo\.png/)).toBeInTheDocument(); + }); + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); + + it("should show warning for audio attachment on text+image target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["audio"], "sound.mp3", { type: "audio/mpeg" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/may not support audio files/)).toBeInTheDocument(); + }); + }); + + it("should show warning listing multiple unsupported types", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const files = [ + new File(["img"], "photo.png", { type: "image/png" }), + new File(["audio"], "sound.mp3", { type: "audio/mpeg" }), + ]; + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, files); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/may not support image, audio files/)).toBeInTheDocument(); + }); + }); + + it("should still allow sending with unsupported attachment type", async () => { + const user = userEvent.setup(); + const onSend = jest.fn(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + }); + + // Send should still be enabled (warning is non-blocking) + expect(getSendButton()).toBeEnabled(); + await user.click(getSendButton()); + expect(onSend).toHaveBeenCalled(); + }); + + it("should show warning for file attachment to text-only target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["pdf content"], "document.pdf", { type: "application/pdf" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/may not support file files/)).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 615244d0e9..c8805f6e78 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -9,6 +9,7 @@ import { import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' +import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // --------------------------------------------------------------------------- // Reusable status banner @@ -170,6 +171,29 @@ function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput, ) } +// --------------------------------------------------------------------------- +// Unsupported modality helper +// --------------------------------------------------------------------------- + +function getUnsupportedAttachmentTypes( + attachments: MessageAttachment[], + activeTarget: TargetInstance | null | undefined, +): string[] { + if (!activeTarget?.supported_input_data_types || attachments.length === 0) return [] + const supported = new Set(activeTarget.supported_input_data_types) + const unsupported: string[] = [] + const seen = new Set() + for (const att of attachments) { + if (seen.has(att.type)) continue + seen.add(att.type) + const dataType = PIECE_TYPE_TO_DATA_TYPE[att.type] + if (dataType && !supported.has(dataType)) { + unsupported.push(att.type) + } + } + return unsupported +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -210,6 +234,9 @@ const ChatInputArea = forwardRef(functi const fileInputRef = useRef(null) const textareaRef = useRef(null) + // Derive unsupported attachment types (no useEffect — pure render computation) + const unsupportedTypes = getUnsupportedAttachmentTypes(attachments, activeTarget) + useImperativeHandle(ref, () => ({ addAttachment: (att: MessageAttachment) => { setAttachments(prev => [...prev, att]) @@ -426,6 +453,14 @@ const ChatInputArea = forwardRef(functi formatFileSize={formatFileSize} styles={styles} /> + {unsupportedTypes.length > 0 && ( +
+ + + This target may not support {unsupportedTypes.join(', ')} files + +
+ )} = { image: 'image_path', audio: 'audio_path', video: 'video_path', + file: 'binary_path', } export interface PieceConversion { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 07d5865123..de1fe2a0f0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -63,6 +63,7 @@ export interface TargetInstance { top_p?: number | null max_requests_per_minute?: number | null supports_multi_turn?: boolean + supported_input_data_types?: string[] target_specific_params?: Record | null } diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index 1d72822690..1f04b7031d 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -53,5 +53,8 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge top_p=params.get("top_p"), max_requests_per_minute=params.get("max_requests_per_minute"), supports_multi_turn=target_obj.capabilities.supports_multi_turn, + supported_input_data_types=sorted( + {str(t) for combo in target_obj.capabilities.input_modalities for t in combo} + ), target_specific_params=combined_specific, ) diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index fef7cbe41e..84b7bad9e7 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -37,6 +37,10 @@ class TargetInstance(BaseModel): top_p: Optional[float] = Field(None, description="Top-p parameter for generation") max_requests_per_minute: Optional[int] = Field(None, description="Maximum requests per minute") supports_multi_turn: bool = Field(True, description="Whether the target supports multi-turn conversation history") + supported_input_data_types: list[str] = Field( + default_factory=lambda: ["text"], + description="Flat list of distinct input data types the target accepts (e.g., ['text', 'image_path'])", + ) target_specific_params: Optional[dict[str, Any]] = Field(None, description="Additional target-specific parameters") diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 9eda90ab5b..f81c05dffe 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -1257,6 +1257,55 @@ def test_chat_target_extra_params_preserved(self) -> None: assert result.target_specific_params["seed"] == 42 assert result.target_specific_params["max_completion_tokens"] == 2048 + def test_supported_input_data_types_text_only_default(self) -> None: + """Test that a target with default capabilities reports only 'text'.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities() + mock_identifier = ComponentIdentifier(class_name="TextTarget", class_module="pyrit.prompt_target") + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.supported_input_data_types == ["text"] + + def test_supported_input_data_types_multimodal(self) -> None: + """Test that a multimodal target reports all individual input types.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities( + input_modalities=frozenset({ + frozenset({"text"}), + frozenset({"image_path"}), + frozenset({"text", "image_path"}), + }), + ) + mock_identifier = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.supported_input_data_types == ["image_path", "text"] + + def test_supported_input_data_types_audio_video(self) -> None: + """Test that a target supporting audio and video reports those types.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities( + input_modalities=frozenset({ + frozenset({"text"}), + frozenset({"audio_path"}), + frozenset({"image_path"}), + frozenset({"text", "audio_path", "image_path"}), + }), + ) + mock_identifier = ComponentIdentifier(class_name="RealtimeTarget", class_module="pyrit.prompt_target") + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.supported_input_data_types == ["audio_path", "image_path", "text"] + # ============================================================================ # Converter Mapper Tests From 50ff2b77c7a78501fab120ba693b312d5818c469 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 5 May 2026 16:19:50 -0700 Subject: [PATCH 02/10] FIX: Block sending when target doesn't support attachment modality Change the unsupported modality warning from non-blocking to blocking: - Disable the send button when unsupported attachment types are present - Guard handleSend against unsupported types - Update warning text to instruct user to remove the attachment - Update tests to verify send is disabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/Chat/ChatInputArea.test.tsx | 16 +++++++--------- frontend/src/components/Chat/ChatInputArea.tsx | 6 +++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index 737481cf7c..a52081e0d4 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -674,7 +674,7 @@ describe("ChatInputArea", () => { await waitFor(() => { expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); - expect(screen.getByText(/may not support image files/)).toBeInTheDocument(); + expect(screen.getByText(/does not support image files/)).toBeInTheDocument(); }); }); @@ -745,7 +745,7 @@ describe("ChatInputArea", () => { await waitFor(() => { expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); - expect(screen.getByText(/may not support audio files/)).toBeInTheDocument(); + expect(screen.getByText(/does not support audio files/)).toBeInTheDocument(); }); }); @@ -774,11 +774,11 @@ describe("ChatInputArea", () => { await waitFor(() => { expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); - expect(screen.getByText(/may not support image, audio files/)).toBeInTheDocument(); + expect(screen.getByText(/does not support image, audio files/)).toBeInTheDocument(); }); }); - it("should still allow sending with unsupported attachment type", async () => { + it("should disable send button with unsupported attachment type", async () => { const user = userEvent.setup(); const onSend = jest.fn(); @@ -804,10 +804,8 @@ describe("ChatInputArea", () => { expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); }); - // Send should still be enabled (warning is non-blocking) - expect(getSendButton()).toBeEnabled(); - await user.click(getSendButton()); - expect(onSend).toHaveBeenCalled(); + // Send should be disabled (warning is blocking) + expect(getSendButton()).toBeDisabled(); }); it("should show warning for file attachment to text-only target", async () => { @@ -832,7 +830,7 @@ describe("ChatInputArea", () => { await waitFor(() => { expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); - expect(screen.getByText(/may not support file files/)).toBeInTheDocument(); + expect(screen.getByText(/does not support file files/)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index c8805f6e78..355ebe7008 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -285,7 +285,7 @@ const ChatInputArea = forwardRef(functi } const handleSend = () => { - if ((input || attachments.length > 0) && !disabled) { + if ((input || attachments.length > 0) && !disabled && unsupportedTypes.length === 0) { onSend(input, convertedValue ?? undefined, attachments) setInput('') setAttachments([]) @@ -457,7 +457,7 @@ const ChatInputArea = forwardRef(functi
- This target may not support {unsupportedTypes.join(', ')} files + This target does not support {unsupportedTypes.join(', ')} files. Remove the attachment to send.
)} @@ -489,7 +489,7 @@ const ChatInputArea = forwardRef(functi appearance="primary" icon={} onClick={handleSend} - disabled={disabled || (!input && attachments.length === 0)} + disabled={disabled || (!input && attachments.length === 0) || unsupportedTypes.length > 0} title="Send message" data-testid="send-message-btn" /> From 0d9eb9075606a26e5690bbc0c0b637cf82d73cd9 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 6 May 2026 05:31:48 -0700 Subject: [PATCH 03/10] FEAT: Block sending when converter output type is unsupported by target Extend modality validation to also check converter output data types, not just raw attachment types. When a converter transforms text into an unsupported type (e.g., text-to-image on a text-only target), the send button is now disabled with a warning. Changes: - Add outputDataType to PieceConversion interface - Pass output type from ConverterPanel through ConverterPreview - Pass converterOutputDataTypes from ChatWindow to ChatInputArea - Validate converter outputs against target supported_input_data_types - Update all affected tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Chat/ChatInputArea.test.tsx | 43 +++++++++++++++++++ .../src/components/Chat/ChatInputArea.tsx | 28 ++++++++---- frontend/src/components/Chat/ChatWindow.tsx | 1 + .../components/Chat/ConverterPanel.test.tsx | 1 + .../Chat/ConverterPanel/ConverterPanel.tsx | 1 + .../ConverterPanel/ConverterPreview.test.tsx | 3 ++ .../Chat/ConverterPanel/ConverterPreview.tsx | 4 +- .../src/components/Chat/converterTypes.ts | 1 + 8 files changed, 73 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index a52081e0d4..702c962560 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -833,4 +833,47 @@ describe("ChatInputArea", () => { expect(screen.getByText(/does not support file files/)).toBeInTheDocument(); }); }); + + it("should block sending when converter output type is unsupported by target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const input = screen.getByRole("textbox"); + await user.type(input, "convert this to image"); + + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support image_path/)).toBeInTheDocument(); + expect(getSendButton()).toBeDisabled(); + }); + + it("should not block when converter output type is supported by target", () => { + render( + + + + ); + + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 355ebe7008..0e86c1c108 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -175,22 +175,33 @@ function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput, // Unsupported modality helper // --------------------------------------------------------------------------- -function getUnsupportedAttachmentTypes( +function getUnsupportedDataTypes( attachments: MessageAttachment[], + converterOutputDataTypes: string[], activeTarget: TargetInstance | null | undefined, ): string[] { - if (!activeTarget?.supported_input_data_types || attachments.length === 0) return [] + if (!activeTarget?.supported_input_data_types) return [] const supported = new Set(activeTarget.supported_input_data_types) const unsupported: string[] = [] const seen = new Set() + + // Check attachment types for (const att of attachments) { - if (seen.has(att.type)) continue - seen.add(att.type) const dataType = PIECE_TYPE_TO_DATA_TYPE[att.type] - if (dataType && !supported.has(dataType)) { + if (dataType && !seen.has(dataType) && !supported.has(dataType)) { + seen.add(dataType) unsupported.push(att.type) } } + + // Check converter output types (e.g., text-to-image converter producing image_path) + for (const dataType of converterOutputDataTypes) { + if (!seen.has(dataType) && !supported.has(dataType)) { + seen.add(dataType) + unsupported.push(dataType) + } + } + return unsupported } @@ -223,19 +234,20 @@ interface ChatInputAreaProps { originalValue?: string | null onClearConversion: () => void onConvertedValueChange: (value: string) => void + converterOutputDataTypes?: string[] mediaConversions?: Array<{ pieceType: string; convertedValue: string }> onClearMediaConversion: (pieceType: string) => void } -const ChatInputArea = forwardRef(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, mediaConversions = [], onClearMediaConversion }, ref) { +const ChatInputArea = forwardRef(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 styles = useChatInputAreaStyles() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const fileInputRef = useRef(null) const textareaRef = useRef(null) - // Derive unsupported attachment types (no useEffect — pure render computation) - const unsupportedTypes = getUnsupportedAttachmentTypes(attachments, activeTarget) + // Derive unsupported types from attachments AND converter outputs + const unsupportedTypes = getUnsupportedDataTypes(attachments, converterOutputDataTypes, activeTarget) useImperativeHandle(ref, () => ({ addAttachment: (att: MessageAttachment) => { diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 89d32be4b1..ef3ae2a547 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -591,6 +591,7 @@ export default function ChatWindow({ if (!existing) return prev return { ...prev, text: { ...existing, convertedValue: val } } })} + converterOutputDataTypes={Object.values(pieceConversions).map((c) => c.outputDataType)} mediaConversions={Object.entries(pieceConversions) .filter(([k]) => k !== 'text') .map(([k, v]) => ({ pieceType: k, convertedValue: v.convertedValue }))} diff --git a/frontend/src/components/Chat/ConverterPanel.test.tsx b/frontend/src/components/Chat/ConverterPanel.test.tsx index 0c6bf567d9..dcc707c759 100644 --- a/frontend/src/components/Chat/ConverterPanel.test.tsx +++ b/frontend/src/components/Chat/ConverterPanel.test.tsx @@ -416,6 +416,7 @@ describe('ConverterPanel use converted value', () => { converterInstanceId: 'conv-1', convertedValue: 'aGVsbG8=', originalValue: 'hello', + outputDataType: 'text', }) }) }) diff --git a/frontend/src/components/Chat/ConverterPanel/ConverterPanel.tsx b/frontend/src/components/Chat/ConverterPanel/ConverterPanel.tsx index 66e3d0e6ab..d22a7e3c5b 100644 --- a/frontend/src/components/Chat/ConverterPanel/ConverterPanel.tsx +++ b/frontend/src/components/Chat/ConverterPanel/ConverterPanel.tsx @@ -403,6 +403,7 @@ export default function ConverterPanel({ onClose, previewText = '', attachmentDa previewConverterInstanceId={previewConverterInstanceId} onPreview={handlePreview} onUseConvertedValue={onUseConvertedValue} + outputDataType={(selectedConverter?.supported_output_types ?? [])[0] ?? 'text'} /> )} diff --git a/frontend/src/components/Chat/ConverterPanel/ConverterPreview.test.tsx b/frontend/src/components/Chat/ConverterPanel/ConverterPreview.test.tsx index dff3ac6000..74a018a101 100644 --- a/frontend/src/components/Chat/ConverterPanel/ConverterPreview.test.tsx +++ b/frontend/src/components/Chat/ConverterPanel/ConverterPreview.test.tsx @@ -19,6 +19,7 @@ function renderPreview(overrides: Partial = {}) { previewConverterInstanceId: null, onPreview: jest.fn(), onUseConvertedValue: jest.fn(), + outputDataType: 'text', } return render( @@ -197,6 +198,7 @@ describe('Use Converted Value button', () => { converterInstanceId: 'conv-1', convertedValue: 'aGVsbG8=', originalValue: 'hello', + outputDataType: 'text', }) }) @@ -216,6 +218,7 @@ describe('Use Converted Value button', () => { converterInstanceId: 'conv-2', convertedValue: '/path/to/output.png', originalValue: 'data:image/png;base64,abc', + outputDataType: 'text', }) }) diff --git a/frontend/src/components/Chat/ConverterPanel/ConverterPreview.tsx b/frontend/src/components/Chat/ConverterPanel/ConverterPreview.tsx index ccf4ac1886..cd596467cb 100644 --- a/frontend/src/components/Chat/ConverterPanel/ConverterPreview.tsx +++ b/frontend/src/components/Chat/ConverterPanel/ConverterPreview.tsx @@ -14,9 +14,10 @@ export interface ConverterPreviewProps { previewConverterInstanceId: string | null onPreview: () => void onUseConvertedValue?: (conversion: PieceConversion) => void + outputDataType: string } -export default function ConverterPreview({ activeTab, previewText, attachmentData, selectedConverterType, isPreviewing, previewError, previewOutput, previewConverterInstanceId, onPreview, onUseConvertedValue }: ConverterPreviewProps) { +export default function ConverterPreview({ activeTab, previewText, attachmentData, selectedConverterType, isPreviewing, previewError, previewOutput, previewConverterInstanceId, onPreview, onUseConvertedValue, outputDataType }: ConverterPreviewProps) { const styles = useConverterPanelStyles() return ( @@ -95,6 +96,7 @@ export default function ConverterPreview({ activeTab, previewText, attachmentDat converterInstanceId: previewConverterInstanceId, convertedValue: previewOutput, originalValue: activeTab === 'text' ? previewText : (attachmentData[activeTab] ?? ''), + outputDataType, })} disabled={!onUseConvertedValue} data-testid="use-converted-btn" diff --git a/frontend/src/components/Chat/converterTypes.ts b/frontend/src/components/Chat/converterTypes.ts index bb94203bb7..91e004c211 100644 --- a/frontend/src/components/Chat/converterTypes.ts +++ b/frontend/src/components/Chat/converterTypes.ts @@ -11,4 +11,5 @@ export interface PieceConversion { convertedValue: string originalValue: string pieceType: string + outputDataType: string } From 5a9e735c74c364ada53970de9fbfdb96419cbe02 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 6 May 2026 05:40:06 -0700 Subject: [PATCH 04/10] FIX: Expand textarea max height and unify scrollbar styling - Increase textInput and convertedTextarea maxHeight from 96px/80px to 30vh each, allowing each to use up to ~30% of viewport height - Remove JS-side 96px cap on auto-resize so CSS maxHeight controls it - Add matching webkit scrollbar styling to convertedTextarea - Use alignItems flex-start on both rows so they grow naturally Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Chat/ChatInputArea.styles.ts | 22 +++++++++++++++---- .../src/components/Chat/ChatInputArea.tsx | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index 378b07f6b0..15c9b01669 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -89,13 +89,17 @@ export const useChatInputAreaStyles = makeStyles({ }, textRow: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: tokens.spacingHorizontalXXS, + flex: 1, + minHeight: 0, }, convertedRow: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: tokens.spacingHorizontalXXS, + flex: 1, + minHeight: 0, }, textInput: { flex: 1, @@ -107,7 +111,7 @@ export const useChatInputAreaStyles = makeStyles({ color: tokens.colorNeutralForeground1, resize: 'none', minHeight: '24px', - maxHeight: '96px', + maxHeight: '30vh', overflowY: 'auto', '::placeholder': { color: tokens.colorNeutralForeground4, @@ -204,11 +208,21 @@ export const useChatInputAreaStyles = makeStyles({ color: tokens.colorNeutralForeground1, resize: 'none', minHeight: '20px', - maxHeight: '80px', + maxHeight: '30vh', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: 0, + '::-webkit-scrollbar': { + width: '8px', + }, + '::-webkit-scrollbar-track': { + backgroundColor: 'transparent', + }, + '::-webkit-scrollbar-thumb': { + backgroundColor: tokens.colorNeutralStroke1, + borderRadius: '4px', + }, }, convertedMediaPreview: { maxHeight: '60px', diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 0e86c1c108..2254735a3e 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -327,7 +327,7 @@ const ChatInputArea = forwardRef(functi useLayoutEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto' - textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 96) + 'px' + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' } onInputChange(input) }, [input, onInputChange]) From 01b204b4f1b330acb9c40d63ffdb1fbfbc7cc448 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 6 May 2026 05:45:58 -0700 Subject: [PATCH 05/10] FIX: Share textarea space equally between original and converted When a conversion is active, both textareas now cap at 15vh each (half of the 30vh total) via inline style overrides. Without a conversion, the original textarea gets the full 30vh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/components/Chat/ChatInputArea.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 2254735a3e..8c571450d4 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -129,6 +129,8 @@ interface TextInputRowsProps { } function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput, onKeyDown, onConvertedValueChange, onClearConversion, styles }: TextInputRowsProps) { + const hasConversion = convertedValue != null && convertedValue !== '' + const sharedMaxHeight = hasConversion ? '15vh' : undefined return ( <>
@@ -138,6 +140,7 @@ function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput,