Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1bad482
πŸ€– Fix: Images not visible to AI model
ammar-agent Oct 17, 2025
0286da3
test: Add integration tests for image support through IPC
ammar-agent Oct 17, 2025
a8aec19
refactor: Extract test helpers for image support tests
ammar-agent Oct 17, 2025
35084b5
refactor: Replace more collector patterns with waitForStreamSuccess
ammar-agent Oct 17, 2025
920e676
fix: Remove stray closing brace in helpers.ts
ammar-agent Oct 17, 2025
3780414
fix: Import modelString from helpers
ammar-agent Oct 17, 2025
b0362af
fix: TypeScript errors in test helpers and image tests
ammar-agent Oct 17, 2025
2d23a2d
fix: Access textDelta from delta property in StreamDeltaEvent
ammar-agent Oct 17, 2025
bccf320
fix: Add imageParts to sendMessage helper type signature
ammar-agent Oct 17, 2025
fd14724
fix: Format test files with Prettier
ammar-agent Oct 17, 2025
6532732
πŸ€– feat: Add drag-and-drop image support + refactor ChatInput
ammar-agent Oct 18, 2025
54d7af3
πŸ€– improve: Add detailed debugging info for image validation errors
ammar-agent Oct 18, 2025
5c6e4c5
πŸ€– fix: Handle drag-and-drop files with missing MIME type
ammar-agent Oct 18, 2025
a06abbf
πŸ€– fix: ESLint errors in drag-and-drop implementation
ammar-agent Oct 18, 2025
0b14c62
πŸ€– fix: TypeScript strict casting in test mocks
ammar-agent Oct 18, 2025
a86d1a5
πŸ€– fix: Address P1 Codex feedback on image handling
ammar-agent Oct 18, 2025
d8374fd
πŸ€– fix: Add missing import and type guard for CmuxImagePart
ammar-agent Oct 18, 2025
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
282 changes: 63 additions & 219 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import React, { useState, useRef, useCallback, useEffect, useId } from "react";
import styled from "@emotion/styled";
import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions";
import type { Toast } from "./ChatInputToast";
import { ChatInputToast, SolutionLabel } from "./ChatInputToast";
import type { ParsedCommand } from "@/utils/slashCommands/types";
import { ChatInputToast } from "./ChatInputToast";
import { createCommandToast, createErrorToast } from "./ChatInputToasts";
import { parseCommand } from "@/utils/slashCommands/parser";
import type { SendMessageError as SendMessageErrorType } from "@/types/errors";
import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedState";
import { useMode } from "@/contexts/ModeContext";
import { ChatToggles } from "./ChatToggles";
Expand All @@ -25,6 +24,11 @@ import { ModelSelector, type ModelSelectorRef } from "./ModelSelector";
import { useModelLRU } from "@/hooks/useModelLRU";
import { VimTextArea } from "./VimTextArea";
import { ImageAttachments, type ImageAttachment } from "./ImageAttachments";
import {
extractImagesFromClipboard,
extractImagesFromDrop,
processImageFiles,
} from "@/utils/imageHandling";

import type { ThinkingLevel } from "@/types/thinking";
import type { CmuxFrontendMetadata, CompactionRequestData } from "@/types/message";
Expand Down Expand Up @@ -138,196 +142,6 @@ export interface ChatInputProps {
}

// Helper function to convert parsed command to display toast
const createCommandToast = (parsed: ParsedCommand): Toast | null => {
if (!parsed) return null;

switch (parsed.type) {
case "providers-help":
return {
id: Date.now().toString(),
type: "error",
title: "Providers Command",
message: "Configure AI provider settings",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/providers set &lt;provider&gt; &lt;key&gt; &lt;value&gt;
<br />
<br />
<SolutionLabel>Example:</SolutionLabel>
/providers set anthropic apiKey YOUR_API_KEY
</>
),
};

case "providers-missing-args": {
const missing =
parsed.argCount === 0
? "provider, key, and value"
: parsed.argCount === 1
? "key and value"
: parsed.argCount === 2
? "value"
: "";

return {
id: Date.now().toString(),
type: "error",
title: "Missing Arguments",
message: `Missing ${missing} for /providers set`,
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/providers set &lt;provider&gt; &lt;key&gt; &lt;value&gt;
</>
),
};
}

case "providers-invalid-subcommand":
return {
id: Date.now().toString(),
type: "error",
title: "Invalid Subcommand",
message: `Invalid subcommand '${parsed.subcommand}'`,
solution: (
<>
<SolutionLabel>Available Commands:</SolutionLabel>
/providers set - Configure provider settings
</>
),
};

case "model-help":
return {
id: Date.now().toString(),
type: "error",
title: "Model Command",
message: "Select AI model for this session",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/model &lt;abbreviation&gt; or /model &lt;provider:model&gt;
<br />
<br />
<SolutionLabel>Examples:</SolutionLabel>
/model sonnet
<br />
/model anthropic:opus-4-1
</>
),
};

case "telemetry-help":
return {
id: Date.now().toString(),
type: "error",
title: "Telemetry Command",
message: "Enable or disable usage telemetry",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/telemetry &lt;on|off&gt;
<br />
<br />
<SolutionLabel>Examples:</SolutionLabel>
/telemetry off
<br />
/telemetry on
</>
),
};

case "fork-help":
return {
id: Date.now().toString(),
type: "error",
title: "Fork Command",
message: "Fork current workspace with a new name",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/fork &lt;new-name&gt; [optional start message]
<br />
<br />
<SolutionLabel>Examples:</SolutionLabel>
/fork experiment-branch
<br />
/fork refactor Continue with refactoring approach
</>
),
};

case "unknown-command": {
const cmd = "/" + parsed.command + (parsed.subcommand ? " " + parsed.subcommand : "");
return {
id: Date.now().toString(),
type: "error",
message: `Unknown command: ${cmd}`,
};
}

default:
return null;
}
};

// Helper function to convert SendMessageError to Toast
const createErrorToast = (error: SendMessageErrorType): Toast => {
switch (error.type) {
case "api_key_not_found":
return {
id: Date.now().toString(),
type: "error",
title: "API Key Not Found",
message: `The ${error.provider} provider requires an API key to function.`,
solution: (
<>
<SolutionLabel>Quick Fix:</SolutionLabel>
/providers set {error.provider} apiKey YOUR_API_KEY
</>
),
};

case "provider_not_supported":
return {
id: Date.now().toString(),
type: "error",
title: "Provider Not Supported",
message: `The ${error.provider} provider is not supported yet.`,
solution: (
<>
<SolutionLabel>Try This:</SolutionLabel>
Use an available provider from /providers list
</>
),
};

case "invalid_model_string":
return {
id: Date.now().toString(),
type: "error",
title: "Invalid Model Format",
message: error.message,
solution: (
<>
<SolutionLabel>Expected Format:</SolutionLabel>
provider:model-name (e.g., anthropic:claude-opus-4-1)
</>
),
};

case "unknown":
default:
return {
id: Date.now().toString(),
type: "error",
title: "Message Send Failed",
message: error.raw || "An unexpected error occurred while sending your message.",
};
}
};

/**
* Prepare compaction message from /compact command
* Returns the actual message text (summarization request), metadata, and options
Expand Down Expand Up @@ -572,37 +386,42 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const items = e.clipboardData?.items;
if (!items) return;

// Look for image items in clipboard
for (const item of Array.from(items)) {
if (!item?.type.startsWith("image/")) continue;

e.preventDefault(); // Prevent default paste behavior for images
const imageFiles = extractImagesFromClipboard(items);
if (imageFiles.length === 0) return;

const file = item.getAsFile();
if (!file) continue;
e.preventDefault(); // Prevent default paste behavior for images

// Convert to base64 data URL
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (dataUrl) {
const attachment: ImageAttachment = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
dataUrl,
mimeType: file.type,
};
setImageAttachments((prev) => [...prev, attachment]);
}
};
reader.readAsDataURL(file);
}
void processImageFiles(imageFiles).then((attachments) => {
setImageAttachments((prev) => [...prev, ...attachments]);
});
}, []);

// Handle removing an image attachment
const handleRemoveImage = useCallback((id: string) => {
setImageAttachments((prev) => prev.filter((img) => img.id !== id));
}, []);

// Handle drag over to allow drop
const handleDragOver = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
// Check if drag contains files
if (e.dataTransfer.types.includes("Files")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
}, []);

// Handle drop to extract images
const handleDrop = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();

const imageFiles = extractImagesFromDrop(e.dataTransfer);
if (imageFiles.length === 0) return;

void processImageFiles(imageFiles).then((attachments) => {
setImageAttachments((prev) => [...prev, ...attachments]);
});
}, []);

// Handle command selection
const handleCommandSelect = useCallback(
(suggestion: SlashSuggestion) => {
Expand Down Expand Up @@ -818,10 +637,33 @@ export const ChatInput: React.FC<ChatInputProps> = ({

try {
// Prepare image parts if any
const imageParts = imageAttachments.map((img) => ({
image: img.dataUrl,
mimeType: img.mimeType,
}));
const imageParts = imageAttachments.map((img, index) => {
// Validate before sending to help with debugging
if (!img.url || typeof img.url !== "string") {
console.error(
`Image attachment [${index}] has invalid url:`,
typeof img.url,
img.url?.slice(0, 50)
);
}
if (!img.url?.startsWith("data:")) {
console.error(
`Image attachment [${index}] url is not a data URL:`,
img.url?.slice(0, 100)
);
}
if (!img.mediaType || typeof img.mediaType !== "string") {
console.error(
`Image attachment [${index}] has invalid mediaType:`,
typeof img.mediaType,
img.mediaType
);
}
return {
url: img.url,
mediaType: img.mediaType,
};
});

// When editing a /compact command, regenerate the actual summarization request
let actualMessageText = messageText;
Expand Down Expand Up @@ -982,6 +824,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onChange={setInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDrop={handleDrop}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
placeholder={placeholder}
disabled={!editingMessage && (disabled || isSending || isCompacting)}
Expand Down
Loading