feat(app): add drag-and-drop file upload and file picker to composer (#1474)
- Desktop web: drag-and-drop non-image files onto the chat pane to upload - Desktop web: file picker option in the composer attachment sheet - Uploaded files are sent to the LLM as filename references in a tmp directory - New useFilePicker hook for cross-platform file picking - Extended useFileDropZone to support generic file drops - Added UploadedFileAttachment type to protocol and composer attachment system - Fixed adaptive modal sheet closing on backdrop press in selection mode
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
GitHubSearchItem,
|
||||
UploadedFileAttachment,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
@@ -61,6 +65,7 @@ export type PullRequestContextAttachment =
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
|
||||
|
||||
@@ -453,6 +453,7 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
}
|
||||
@@ -467,6 +468,7 @@ export function AdaptiveModalSheet({
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
@@ -602,7 +604,12 @@ export function AdaptiveModalSheet({
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||
<FileDropZone
|
||||
onFilesDropped={onFilesDropped}
|
||||
onGenericFilesDropped={onGenericFilesDropped}
|
||||
>
|
||||
{cardInner}
|
||||
</FileDropZone>
|
||||
) : (
|
||||
cardInner
|
||||
)}
|
||||
|
||||
@@ -4,23 +4,30 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: DroppedItem[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
export function FileDropZone({
|
||||
children,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
}: FileDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled,
|
||||
});
|
||||
|
||||
@@ -60,7 +67,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -443,6 +443,8 @@ function getUserMessageAttachmentLabel(
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? t("message.attachments.textAttachment");
|
||||
case "uploaded_file":
|
||||
return attachment.fileName;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ function createFakeSendClient(
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
uploadFile: async () => ({ requestId: "test", file: null, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,18 @@ export interface ComposerSendClient {
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
|
||||
requestId: string;
|
||||
file: {
|
||||
type: "uploaded_file";
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ComposerCancelClient {
|
||||
@@ -95,6 +107,23 @@ export async function pickAndPersistImages(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadFileAttachments(input: {
|
||||
client: ComposerSendClient;
|
||||
files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>;
|
||||
}): Promise<Extract<ComposerAttachment, { kind: "file" }>[]> {
|
||||
const result: Extract<ComposerAttachment, { kind: "file" }>[] = [];
|
||||
|
||||
for (const file of input.files) {
|
||||
const response = await input.client.uploadFile(file);
|
||||
if (response.error || !response.file) {
|
||||
throw new Error(response.error ?? "Upload failed.");
|
||||
}
|
||||
result.push({ kind: "file", attachment: response.file });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||
attachments: T[];
|
||||
index: number;
|
||||
@@ -297,6 +326,9 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (input.attachment.kind === "file") {
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||
return;
|
||||
@@ -308,12 +340,18 @@ export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttac
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
function isGithubAttachment(
|
||||
attachment: UserComposerAttachment,
|
||||
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
|
||||
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
|
||||
}
|
||||
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
@@ -335,7 +373,7 @@ export function toggleGithubAttachmentFromPicker({
|
||||
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
|
||||
const existingAttachment = current.find(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
@@ -358,7 +396,7 @@ export function isAttachmentSelectedForGithubItem(
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
isGithubAttachment(attachment) &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
attachments: AgentAttachment[];
|
||||
} {
|
||||
const images: ImageAttachment[] = [];
|
||||
const reviewAttachments: AgentAttachment[] = [];
|
||||
const agentAttachments: AgentAttachment[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.kind === "image") {
|
||||
@@ -20,22 +20,27 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.kind === "file") {
|
||||
agentAttachments.push(attachment.attachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
reviewAttachments.push(workspaceAttachment);
|
||||
agentAttachments.push(workspaceAttachment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
||||
if (reviewAttachment) {
|
||||
reviewAttachments.push(reviewAttachment);
|
||||
agentAttachments.push(reviewAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
images,
|
||||
attachments: reviewAttachments,
|
||||
attachments: agentAttachments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Pencil,
|
||||
AudioLines,
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Github,
|
||||
Paperclip,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
uploadFileAttachments,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
@@ -114,6 +117,7 @@ type AttachmentListUpdater =
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
|
||||
function noop() {}
|
||||
const noopCallback = () => {};
|
||||
|
||||
function resolveComposerButtonIconSize(): number {
|
||||
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
@@ -259,6 +263,7 @@ interface RenderAttachmentTrayArgs {
|
||||
labels: {
|
||||
openImage: string;
|
||||
removeImage: string;
|
||||
removeFile: string;
|
||||
openGithub: (kind: string, number: number) => string;
|
||||
removeGithub: (kind: string, number: number) => string;
|
||||
};
|
||||
@@ -359,6 +364,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (attachment.kind === "file") {
|
||||
return (
|
||||
<FileAttachmentPill
|
||||
key={attachment.attachment.id}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
removeLabel={labels.removeFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
return composerWorkspaceAttachment.renderPill({
|
||||
attachment,
|
||||
@@ -642,6 +659,45 @@ function GithubAttachmentPill({
|
||||
);
|
||||
}
|
||||
|
||||
interface FileAttachmentPillProps {
|
||||
attachment: Extract<ComposerAttachment, { kind: "file" }>;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onRemove: (index: number) => void;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
function FileAttachmentPill({
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: FileAttachmentPillProps) {
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [onRemove, index]);
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-file-attachment-pill"
|
||||
onOpen={noopCallback}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={attachment.attachment.fileName}
|
||||
removeAccessibilityLabel={removeLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.filePillBody}>
|
||||
<View style={styles.filePillIcon}>
|
||||
<ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
</View>
|
||||
<Text style={styles.filePillText} numberOfLines={1}>
|
||||
{attachment.attachment.fileName}
|
||||
</Text>
|
||||
</View>
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
|
||||
interface GithubPickerOptionProps {
|
||||
label: string;
|
||||
testID: string;
|
||||
@@ -713,6 +769,8 @@ interface ComposerProps {
|
||||
autoFocus?: boolean;
|
||||
/** Callback to expose the addImages function to parent components */
|
||||
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
/** Callback to expose the addFiles function to parent components */
|
||||
onAddFiles?: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
@@ -734,6 +792,8 @@ interface ComposerProps {
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
@@ -898,6 +958,7 @@ function ComposerVoiceModeButton({
|
||||
);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line complexity
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
@@ -921,6 +982,7 @@ export function Composer({
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
@@ -997,6 +1059,7 @@ export function Composer({
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||
@@ -1073,6 +1136,7 @@ export function Composer({
|
||||
}, [userInput.length]);
|
||||
|
||||
const { pickImages } = useImageAttachmentPicker();
|
||||
const { pickFiles } = useFilePicker();
|
||||
const agentIdRef = useRef(agentId);
|
||||
const sendAgentMessageRef = useRef<
|
||||
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
|
||||
@@ -1094,6 +1158,20 @@ export function Composer({
|
||||
onAddImages?.(addImages);
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
// Expose addFiles function to parent for drag-and-drop support
|
||||
const addFiles = useCallback(
|
||||
(files: UserComposerAttachment[]) => {
|
||||
setSelectedAttachments((prev) => [...prev, ...files]);
|
||||
},
|
||||
[setSelectedAttachments],
|
||||
);
|
||||
|
||||
/* oxlint-disable react-hooks/exhaustive-deps */
|
||||
useEffect(() => {
|
||||
onAddFiles?.(addFiles);
|
||||
}, [addFiles, onAddFiles]);
|
||||
/* oxlint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (isNative) return;
|
||||
focusWithRetries({
|
||||
@@ -1289,6 +1367,36 @@ export function Composer({
|
||||
addImages(newImages);
|
||||
}, [addImages, pickImages]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
if (!client) {
|
||||
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files = await pickFiles();
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
|
||||
if (oversized) {
|
||||
toastErrorRef.current(
|
||||
t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingFile(true);
|
||||
const uploaded = await uploadFileAttachments({ client, files });
|
||||
addFiles(uploaded);
|
||||
} catch (error) {
|
||||
console.error("[Composer] Failed to upload file:", error);
|
||||
toastErrorRef.current(
|
||||
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
}, [client, pickFiles, addFiles, t]);
|
||||
|
||||
const handleRemoveAttachment = useCallback(
|
||||
(index: number) => {
|
||||
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
|
||||
@@ -1596,8 +1704,20 @@ export function Composer({
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
},
|
||||
...(!isNative
|
||||
? [
|
||||
{
|
||||
id: "file",
|
||||
label: t("composer.attachments.addFile"),
|
||||
icon: <ThemedFileText size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
onSelect: () => {
|
||||
void handlePickFile();
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[handlePickImage, t],
|
||||
[handlePickImage, handlePickFile, t],
|
||||
);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
@@ -1698,6 +1818,7 @@ export function Composer({
|
||||
labels: {
|
||||
openImage: t("composer.attachments.openImage"),
|
||||
removeImage: t("composer.attachments.removeImage"),
|
||||
removeFile: t("composer.attachments.removeFile"),
|
||||
openGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.openGithub", { kind, number }),
|
||||
removeGithub: (kind: string, number: number) =>
|
||||
@@ -1721,7 +1842,7 @@ export function Composer({
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitBusy = isProcessing || isSubmitLoading;
|
||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
|
||||
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
||||
const sendErrorNode = useMemo(
|
||||
@@ -1961,6 +2082,20 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
filePillBody: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
filePillIcon: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
filePillText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
githubPillText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
@@ -2030,6 +2165,7 @@ const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAudioLines = withUnistyles(AudioLines);
|
||||
const ThemedPaperclip = withUnistyles(Paperclip);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const ThemedGithub = withUnistyles(Github);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
@@ -4,8 +4,19 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
file: File;
|
||||
}
|
||||
export interface DroppedPathItem {
|
||||
kind: "desktop-path";
|
||||
path: string;
|
||||
}
|
||||
export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: DroppedItem[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -83,18 +94,24 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||
|
||||
export function useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const dragCounterRef = useRef(0);
|
||||
const onFilesDroppedRef = useRef(onFilesDropped);
|
||||
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
|
||||
|
||||
// Keep callback ref up to date
|
||||
// Keep callback refs up to date
|
||||
useEffect(() => {
|
||||
onFilesDroppedRef.current = onFilesDropped;
|
||||
}, [onFilesDropped]);
|
||||
|
||||
useEffect(() => {
|
||||
onGenericFilesDroppedRef.current = onGenericFilesDropped;
|
||||
}, [onGenericFilesDropped]);
|
||||
|
||||
// Reset drag state when disabled changes
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
@@ -156,6 +173,15 @@ export function useFileDropZone({
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const items: DroppedPathItem[] = payload.paths.map((path) => ({
|
||||
kind: "desktop-path",
|
||||
path,
|
||||
}));
|
||||
|
||||
if (onGenericFilesDroppedRef.current && items.length > 0) {
|
||||
onGenericFilesDroppedRef.current(items);
|
||||
}
|
||||
|
||||
const imagePaths = payload.paths.filter(isImagePath);
|
||||
if (imagePaths.length === 0) {
|
||||
return;
|
||||
@@ -238,6 +264,15 @@ export function useFileDropZone({
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||
kind: "web-file",
|
||||
file,
|
||||
}));
|
||||
|
||||
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
|
||||
onGenericFilesDroppedRef.current(genericItems);
|
||||
}
|
||||
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
|
||||
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface PickedFile {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
".js": "text/javascript",
|
||||
".ts": "text/typescript",
|
||||
".tsx": "text/typescript-jsx",
|
||||
".jsx": "text/javascript",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".xml": "text/xml",
|
||||
".csv": "text/csv",
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
};
|
||||
|
||||
function getFileExtension(path: string): string {
|
||||
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
|
||||
const extensionIndex = normalizedPath.lastIndexOf(".");
|
||||
if (extensionIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
return normalizedPath.slice(extensionIndex).toLowerCase();
|
||||
}
|
||||
|
||||
export function getMimeTypeFromPath(path: string): string {
|
||||
const ext = getFileExtension(path);
|
||||
return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
|
||||
const { path: managedPath } = await copyDesktopAttachmentFile({
|
||||
attachmentId: crypto.randomUUID(),
|
||||
sourcePath: path,
|
||||
extension: getFileExtension(path).slice(1) || null,
|
||||
});
|
||||
const base64 = await readDesktopFileBase64(managedPath);
|
||||
return base64ToUint8Array(base64);
|
||||
}
|
||||
|
||||
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
|
||||
const dialog = getDesktopHost()?.dialog;
|
||||
const dialogOpen = dialog?.open;
|
||||
if (typeof dialogOpen !== "function") {
|
||||
throw new Error("Desktop dialog API is not available.");
|
||||
}
|
||||
|
||||
const selection = await dialogOpen({
|
||||
directory: false,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paths = Array.isArray(selection) ? selection : [selection];
|
||||
if (paths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: PickedFile[] = [];
|
||||
|
||||
for (const filePath of paths) {
|
||||
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
|
||||
const mimeType = getMimeTypeFromPath(filePath);
|
||||
|
||||
// Copy into managed storage so we can read it through the existing secure IPC.
|
||||
const { path: managedPath } = await copyDesktopAttachmentFile({
|
||||
attachmentId: crypto.randomUUID(),
|
||||
sourcePath: filePath,
|
||||
extension: getFileExtension(filePath).slice(1) || null,
|
||||
});
|
||||
|
||||
const base64 = await readDesktopFileBase64(managedPath);
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
|
||||
result.push({ fileName, mimeType, bytes });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
|
||||
input.addEventListener("change", async () => {
|
||||
const files = Array.from(input.files ?? []);
|
||||
if (files.length === 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: PickedFile[] = [];
|
||||
for (const file of files) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
result.push({
|
||||
fileName: file.name,
|
||||
mimeType: file.type || getMimeTypeFromPath(file.name),
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
input.addEventListener("cancel", () => {
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
|
||||
// Clean up after a short delay to allow the change event to fire
|
||||
setTimeout(() => {
|
||||
input.remove();
|
||||
}, 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
export function useFilePicker() {
|
||||
const isPickingRef = useRef(false);
|
||||
|
||||
const pickFiles = useCallback(async (): Promise<PickedFile[] | null> => {
|
||||
if (isPickingRef.current) {
|
||||
return null;
|
||||
}
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (isWeb && isElectronRuntime()) {
|
||||
return await pickFilesWithDesktopDialog();
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
return await pickFilesWithWebInput();
|
||||
}
|
||||
|
||||
// Native not supported yet
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[FilePicker] Failed to pick files:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
isPickingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { pickFiles };
|
||||
}
|
||||
@@ -99,12 +99,15 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "أضف صورة",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "أضف مشكلة أو PR",
|
||||
dropImagesHere: "إسقاط الصور هنا",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "تحرير الرسالة في قائمة الانتظار",
|
||||
sendQueuedMessageNow: "إرسال رسالة في قائمة الانتظار الآن",
|
||||
openImage: "فتح مرفق الصورة",
|
||||
removeImage: "إزالة مرفق الصورة",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "افتح{{kind}}#{{number}}",
|
||||
removeGithub: "إزالة{{kind}}#{{number}}",
|
||||
browserElement: "العنصر ·{{tag}}",
|
||||
@@ -119,6 +122,8 @@ export const ar: TranslationResources = {
|
||||
noHostSelected: "لم يتم تحديد مضيف",
|
||||
initialPromptRequired: "مطلوب موجه الأولي",
|
||||
alreadyLoading: "جارٍ التحميل بالفعل",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "أرشفة الوكيل الحالي",
|
||||
|
||||
@@ -97,12 +97,15 @@ export const en = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "Add image",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "Add issue or PR",
|
||||
dropImagesHere: "Drop images here",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "Edit queued message",
|
||||
sendQueuedMessageNow: "Send queued message now",
|
||||
openImage: "Open image attachment",
|
||||
removeImage: "Remove image attachment",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "Open {{kind}} #{{number}}",
|
||||
removeGithub: "Remove {{kind}} #{{number}}",
|
||||
browserElement: "Element · {{tag}}",
|
||||
@@ -117,6 +120,8 @@ export const en = {
|
||||
noHostSelected: "No host selected",
|
||||
initialPromptRequired: "Initial prompt is required",
|
||||
alreadyLoading: "Already loading",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "Archive the current agent",
|
||||
|
||||
@@ -99,12 +99,15 @@ export const es: TranslationResources = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "Agregar imagen",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "Agregar problema oPR",
|
||||
dropImagesHere: "Suelta imágenes aquí",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "Editar mensaje en cola",
|
||||
sendQueuedMessageNow: "Enviar mensaje en cola ahora",
|
||||
openImage: "Abrir imagen adjunta",
|
||||
removeImage: "Quitar imagen adjunta",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "Abrir{{kind}}#{{number}}",
|
||||
removeGithub: "Quitar{{kind}}#{{number}}",
|
||||
browserElement: "Elemento ·{{tag}}",
|
||||
@@ -119,6 +122,8 @@ export const es: TranslationResources = {
|
||||
noHostSelected: "Ningún anfitrión seleccionado",
|
||||
initialPromptRequired: "Se requiere aviso inicial",
|
||||
alreadyLoading: "Ya cargando",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "Archivar el agente actual",
|
||||
|
||||
@@ -100,12 +100,15 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "Ajouter une image",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "Ajouter un problème ouPR",
|
||||
dropImagesHere: "Déposez des images ici",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "Modifier le message en file d'attente",
|
||||
sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant",
|
||||
openImage: "Ouvrir la pièce jointe de l'image",
|
||||
removeImage: "Supprimer l'image jointe",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "Ouvrir{{kind}}#{{number}}",
|
||||
removeGithub: "Supprimer{{kind}}#{{number}}",
|
||||
browserElement: "Élément ·{{tag}}",
|
||||
@@ -120,6 +123,8 @@ export const fr: TranslationResources = {
|
||||
noHostSelected: "Aucun hôte sélectionné",
|
||||
initialPromptRequired: "Une invite initiale est requise",
|
||||
alreadyLoading: "Déjà en cours de chargement",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "Archiver l'agent actuel",
|
||||
|
||||
@@ -99,12 +99,15 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "Добавить изображение",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "Добавить проблему или PR",
|
||||
dropImagesHere: "Скиньте изображения сюда",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "Изменить сообщение в очереди",
|
||||
sendQueuedMessageNow: "Отправить сообщение в очереди сейчас",
|
||||
openImage: "Открыть прикрепленное изображение",
|
||||
removeImage: "Удалить прикрепленное изображение",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "Открыть{{kind}}#{{number}}",
|
||||
removeGithub: "Удалить{{kind}}#{{number}}",
|
||||
browserElement: "Элемент ·{{tag}}",
|
||||
@@ -119,6 +122,8 @@ export const ru: TranslationResources = {
|
||||
noHostSelected: "Хост не выбран",
|
||||
initialPromptRequired: "Требуется начальное приглашение",
|
||||
alreadyLoading: "Уже загружается",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "Архивировать текущего агента",
|
||||
|
||||
@@ -99,12 +99,15 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
attachments: {
|
||||
addImage: "添加图片",
|
||||
addFile: "Upload file",
|
||||
addIssueOrPr: "添加 issue 或 PR",
|
||||
dropImagesHere: "将图片拖放到这里",
|
||||
dropFilesHere: "Drop files here",
|
||||
editQueuedMessage: "编辑排队消息",
|
||||
sendQueuedMessageNow: "立即发送排队消息",
|
||||
openImage: "打开图片附件",
|
||||
removeImage: "移除图片附件",
|
||||
removeFile: "Remove file attachment",
|
||||
openGithub: "打开 {{kind}} #{{number}}",
|
||||
removeGithub: "移除 {{kind}} #{{number}}",
|
||||
browserElement: "元素 · {{tag}}",
|
||||
@@ -119,6 +122,8 @@ export const zhCN: TranslationResources = {
|
||||
noHostSelected: "未选择 Host",
|
||||
initialPromptRequired: "初始 prompt 必填",
|
||||
alreadyLoading: "正在加载",
|
||||
uploadFailed: "Failed to upload file",
|
||||
fileTooLarge: "{{fileName}} is too large (max {{size}})",
|
||||
},
|
||||
clientCommands: {
|
||||
archiveAgent: "归档当前 Agent",
|
||||
|
||||
@@ -15,6 +15,10 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { uploadFileAttachments } from "@/composer/actions";
|
||||
import { readDesktopFileBytes, getMimeTypeFromPath } from "@/hooks/use-file-picker";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -682,6 +686,7 @@ function ChatAgentContent({
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null);
|
||||
const clearOnAgentBlurRef = useRef<() => void>(() => {});
|
||||
const wasPaneFocusedRef = useRef(isPaneFocused);
|
||||
const reconnectToastArmedRef = useRef(false);
|
||||
@@ -698,6 +703,46 @@ function ChatAgentContent({
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const handleAddFilesCallback = useCallback(
|
||||
(addFiles: (files: UserComposerAttachment[]) => void) => {
|
||||
addFilesRef.current = addFiles;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGenericFilesDropped = useCallback(
|
||||
async (items: DroppedItem[]) => {
|
||||
if (!client || !isConnected) return;
|
||||
const nonImageItems = items.filter((item) => {
|
||||
if (item.kind === "web-file") return !item.file.type.startsWith("image/");
|
||||
return !isImagePath(item.path);
|
||||
});
|
||||
if (nonImageItems.length === 0) return;
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
nonImageItems.map(async (item) => {
|
||||
if (item.kind === "web-file") {
|
||||
return {
|
||||
fileName: item.file.name,
|
||||
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
|
||||
bytes: new Uint8Array(await item.file.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
const fileName = item.path.split("/").pop() ?? item.path.split("\\").pop() ?? item.path;
|
||||
const bytes = await readDesktopFileBytes(item.path);
|
||||
return { fileName, mimeType: getMimeTypeFromPath(item.path), bytes };
|
||||
}),
|
||||
);
|
||||
const uploaded = await uploadFileAttachments({ client, files });
|
||||
addFilesRef.current?.(uploaded);
|
||||
} catch (error) {
|
||||
console.error("[AgentPanel] Failed to upload dropped files:", error);
|
||||
panelToast.api.error(error instanceof Error ? error.message : "Failed to upload file");
|
||||
}
|
||||
},
|
||||
[client, isConnected, panelToast.api],
|
||||
);
|
||||
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
@@ -1048,7 +1093,9 @@ function ChatAgentContent({
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
handleGenericFilesDropped={handleGenericFilesDropped}
|
||||
handleAddImagesCallback={handleAddImagesCallback}
|
||||
handleAddFilesCallback={handleAddFilesCallback}
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
@@ -1059,6 +1106,24 @@ function ChatAgentContent({
|
||||
);
|
||||
}
|
||||
|
||||
function isImagePath(path: string): boolean {
|
||||
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
const imageExts = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
".tif",
|
||||
".tiff",
|
||||
]);
|
||||
return imageExts.has(ext);
|
||||
}
|
||||
function ChatAgentReadyContent({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -1072,7 +1137,9 @@ function ChatAgentReadyContent({
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleFilesDropped,
|
||||
handleGenericFilesDropped,
|
||||
handleAddImagesCallback,
|
||||
handleAddFilesCallback,
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
showHistorySyncOverlay,
|
||||
@@ -1092,7 +1159,9 @@ function ChatAgentReadyContent({
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||
handleGenericFilesDropped: (items: DroppedItem[]) => void;
|
||||
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
handleAddFilesCallback: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
showHistorySyncOverlay: boolean;
|
||||
@@ -1135,6 +1204,7 @@ function ChatAgentReadyContent({
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onAddFiles={handleAddFilesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
@@ -1148,7 +1218,11 @@ function ChatAgentReadyContent({
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<FileDropZone
|
||||
onFilesDropped={handleFilesDropped}
|
||||
onGenericFilesDropped={handleGenericFilesDropped}
|
||||
disabled={isArchivingCurrentAgent}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{contentContainer}
|
||||
|
||||
@@ -1258,6 +1332,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onComposerHeightChange,
|
||||
onMessageSent,
|
||||
}: {
|
||||
@@ -1272,6 +1347,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
@@ -1296,6 +1372,7 @@ function AgentComposerSection({
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
onAddFiles={onAddFiles}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
/>
|
||||
@@ -1312,6 +1389,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
onAddFiles,
|
||||
onComposerHeightChange,
|
||||
onMessageSent,
|
||||
}: {
|
||||
@@ -1324,6 +1402,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
@@ -1465,6 +1544,7 @@ function ActiveAgentComposer({
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
onAddFiles={onAddFiles}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
|
||||
@@ -4064,6 +4064,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type UploadedFileAttachment = z.infer<typeof UploadedFileAttachmentSchema>;
|
||||
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
|
||||
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||
export type ListProviderModelsRequestMessage = z.infer<
|
||||
|
||||
Reference in New Issue
Block a user