feat(app): unify composer attachments with pill + lightbox redesign
Images and GitHub issues/PRs now live in one draft-store list (ComposerAttachment union) with cwd persisted on the draft. Paperclip becomes a + drop-up menu; queue button removed. Each pill uses a shared AttachmentPill wrapper with a corner-X that mirrors the auto-update toast spec. Pill body opens the attachment: GitHub via openExternalUrl, images via a new fullscreen AttachmentLightbox. GitHub picker auto-closes on select.
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
TextInputSelectionChangeEventData,
|
||||
Image,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import {
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
|
||||
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
@@ -36,10 +35,16 @@ import {
|
||||
filesToImageAttachments,
|
||||
} from "@/utils/image-attachments-from-files";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
@@ -56,11 +61,20 @@ export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
export interface MessagePayload {
|
||||
text: string;
|
||||
images?: ImageAttachment[];
|
||||
attachments: ComposerAttachment[];
|
||||
cwd: string;
|
||||
/** When true, bypasses queue and sends immediately even if agent is running */
|
||||
forceSend?: boolean;
|
||||
}
|
||||
|
||||
export interface AttachmentMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactElement | null;
|
||||
}
|
||||
|
||||
export interface MessageInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
@@ -73,10 +87,11 @@ export interface MessageInputProps {
|
||||
submitButtonAccessibilityLabel?: string;
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
images?: ImageAttachment[];
|
||||
onPickImages?: () => void;
|
||||
attachments: ComposerAttachment[];
|
||||
cwd: string;
|
||||
attachmentMenuItems: AttachmentMenuItem[];
|
||||
onAttachButtonRef?: (node: View | null) => void;
|
||||
onAddImages?: (images: ImageAttachment[]) => void;
|
||||
onRemoveImage?: (index: number) => void;
|
||||
client: DaemonClient | null;
|
||||
/** Dictation start gate from host runtime (socket connected + directory ready). */
|
||||
isReadyForDictation?: boolean;
|
||||
@@ -194,14 +209,6 @@ function getScrollableAncestorChain(element: HTMLElement | null): string[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
||||
const uri = useAttachmentPreviewUrl(image);
|
||||
if (!uri) {
|
||||
return <View style={styles.imageThumbnailPlaceholder} />;
|
||||
}
|
||||
return <Image source={{ uri }} style={styles.imageThumbnail} />;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
|
||||
{
|
||||
value,
|
||||
@@ -212,10 +219,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
submitButtonAccessibilityLabel,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
onPickImages,
|
||||
attachments,
|
||||
cwd,
|
||||
attachmentMenuItems,
|
||||
onAttachButtonRef,
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
client,
|
||||
isReadyForDictation,
|
||||
placeholder = "Message...",
|
||||
@@ -249,7 +257,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const sendKeys = useShortcutKeys("message-input-send");
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const queueKeys = useShortcutKeys("message-input-queue");
|
||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
@@ -376,15 +383,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
sendAfterTranscriptRef.current = false;
|
||||
|
||||
if (shouldAutoSend) {
|
||||
const imageAttachments = images.length > 0 ? images : undefined;
|
||||
// Respect send behavior setting: when "queue", dictation queues too.
|
||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||
onQueue({ text: nextValue, images: imageAttachments });
|
||||
onQueue({ text: nextValue, attachments, cwd });
|
||||
onChangeText("");
|
||||
} else {
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
attachments,
|
||||
cwd,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
});
|
||||
}
|
||||
@@ -398,7 +405,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
||||
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback(
|
||||
@@ -582,10 +589,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed && images.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
|
||||
if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
|
||||
const payload = {
|
||||
text: trimmed,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
attachments,
|
||||
cwd,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
};
|
||||
onSubmit(payload);
|
||||
@@ -595,7 +603,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, [
|
||||
allowEmptySubmit,
|
||||
value,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
onSubmit,
|
||||
isAgentRunning,
|
||||
onHeightChange,
|
||||
@@ -605,17 +614,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const handleQueueMessage = useCallback(() => {
|
||||
if (!onQueue) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed && images.length === 0) return;
|
||||
if (!trimmed && attachments.length === 0) return;
|
||||
const payload = {
|
||||
text: trimmed,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
attachments,
|
||||
cwd,
|
||||
};
|
||||
onQueue(payload);
|
||||
onChangeText("");
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||
setInputHeight(MIN_INPUT_HEIGHT);
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
||||
}, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]);
|
||||
|
||||
// Default send action: respects the sendBehavior setting.
|
||||
// When "interrupt" (default), primary action sends immediately (interrupts).
|
||||
@@ -950,8 +960,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
handleDefaultSendAction();
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
const hasRealContent = value.trim().length > 0 || hasImages;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const hasRealContent = value.trim().length > 0 || hasAttachments;
|
||||
const hasSendableContent = hasRealContent || hasExternalContent;
|
||||
const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading;
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
@@ -989,36 +999,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
ref={inputWrapperRef}
|
||||
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
|
||||
>
|
||||
{/* Image preview pills */}
|
||||
{hasImages && (
|
||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
||||
{images.map((image, index) => (
|
||||
<Pressable
|
||||
key={`${image.id}-${index}`}
|
||||
testID="message-input-image-pill"
|
||||
style={styles.imagePill}
|
||||
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<ImageAttachmentThumbnail image={image} />
|
||||
{onRemoveImage && (
|
||||
<View
|
||||
style={[
|
||||
styles.removeImageButton,
|
||||
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
||||
]}
|
||||
>
|
||||
<X size={theme.iconSize.md} color="white" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<TextInput
|
||||
@@ -1071,14 +1051,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View style={styles.buttonRow}>
|
||||
{/* Left: attachment button + leftContent slot */}
|
||||
<View style={styles.leftButtonGroup}>
|
||||
{onPickImages && (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPickImages}
|
||||
<DropdownMenuTrigger
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityLabel="Add attachment"
|
||||
accessibilityRole="button"
|
||||
testID="message-input-attach-button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
@@ -1086,18 +1066,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
]}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Paperclip
|
||||
size={buttonIconSize}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<View
|
||||
ref={onAttachButtonRef}
|
||||
collapsable={false}
|
||||
style={styles.attachButtonAnchor}
|
||||
>
|
||||
<Plus
|
||||
size={buttonIconSize}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
<Text style={styles.tooltipText}>Add attachment</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
offset={8}
|
||||
minWidth={220}
|
||||
testID="message-input-attachment-menu"
|
||||
>
|
||||
{attachmentMenuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
testID={`message-input-attachment-menu-item-${item.id}`}
|
||||
disabled={item.disabled}
|
||||
onSelect={item.onSelect}
|
||||
leading={item.icon ?? null}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
@@ -1164,31 +1169,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleAlternateSendAction}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.queueButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={buttonIconSize} color="white" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Queue</Text>
|
||||
{queueKeys ? (
|
||||
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
@@ -1276,52 +1256,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imagePill: {
|
||||
position: "relative",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
...(isWeb
|
||||
? {
|
||||
cursor: "pointer",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
imageThumbnailPlaceholder: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
removeImageButton: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
opacity: 0,
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: "150ms",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
removeImageButtonVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
@@ -1359,7 +1293,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
flexGrow: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
gap: theme.spacing[0],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexShrink: 0,
|
||||
@@ -1374,6 +1308,12 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
attachButtonAnchor: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
voiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -1384,14 +1324,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
voiceButtonRecording: {
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
queueButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
Reference in New Issue
Block a user