Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -3925,6 +3925,26 @@ body {
color: var(--text-secondary);
}

/* Drag-and-drop attachment reorder wrapper */
.attachment-drag-wrapper {
cursor: grab;
transition: opacity 0.15s ease;
}

.attachment-drag-wrapper:active {
cursor: grabbing;
}

.attachment-dragging {
opacity: 0.4;
}

.attachment-drag-over {
outline: 2px dashed var(--border-focus, var(--accent));
outline-offset: 2px;
border-radius: var(--radius-sm);
}

.attachment-chip {
position: relative;
display: inline-flex;
Expand Down
50 changes: 45 additions & 5 deletions src/renderer/src/screens/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const slashMenuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -382,6 +384,35 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
setAttachmentError(null);
}

// Drag-and-drop reordering
function handleDragStart(e: React.DragEvent, index: number): void {
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
}

function handleDragOver(e: React.DragEvent, index: number): void {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
}

function handleDrop(e: React.DragEvent, index: number): void {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
setAttachments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIndex, 1);
next.splice(index, 0, moved);
return next;
});
}
Comment on lines +400 to +409

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Drag state not cleared on successful drop

handleDrop reorders the attachments but leaves dragIndex and dragOverIndex set. The onDragEnd event does fire after onDrop per the HTML5 DnD spec and will clear them, but calling handleDragEnd() directly inside handleDrop makes the cleanup unconditional and symmetrical, and avoids any edge-case flash if onDragEnd is delayed or skipped in unusual browser environments.

Suggested change
function handleDrop(e: React.DragEvent, index: number): void {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
setAttachments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIndex, 1);
next.splice(index, 0, moved);
return next;
});
}
function handleDrop(e: React.DragEvent, index: number): void {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
setAttachments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIndex, 1);
next.splice(index, 0, moved);
return next;
});
handleDragEnd();
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


function handleDragEnd(): void {
setDragIndex(null);
setDragOverIndex(null);
}

// Pre-send validation gate (#369): even with the queue model from
// PR #379, we still block Send when readiness fails — a queued message
// with a missing API key would just fail later. The !isLoading gate
Expand Down Expand Up @@ -455,12 +486,21 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
)}
{(attachments.length > 0 || attachmentError) && (
<div className="chat-attachment-strip">
{attachments.map((att) => (
<AttachmentChip
{attachments.map((att, i) => (
<div
key={att.id}
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
className={`attachment-drag-wrapper${dragOverIndex === i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Drop-target outline shown on the item being dragged

When dragOverIndex === dragIndex === i (the cursor re-enters the source element during a drag), the wrapper receives both attachment-dragging (faded) and attachment-drag-over (dashed outline) at the same time. The outline only makes sense for valid other drop targets; guarding the condition with dragIndex !== i prevents the visual conflict.

Suggested change
className={`attachment-drag-wrapper${dragOverIndex === i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`}
className={`attachment-drag-wrapper${dragOverIndex === i && dragIndex !== i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`}

draggable
onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}
Comment on lines +495 to +497

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dashed outline lingers when cursor leaves the strip

dragOverIndex is only updated when the cursor enters a new wrapper element, so once the cursor exits all attachment wrappers (e.g., moves into the textarea) the outline stays painted on the last-hovered chip for the remainder of the drag. Adding onDragLeave to reset dragOverIndex to null clears it immediately.

Suggested change
onDragOver={(e) => handleDragOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, i)}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}

>
<AttachmentChip
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
</div>
))}
{attachmentError && (
<div className="chat-attachment-error" role="alert">
Expand Down
Loading