feat: import project files into a chat session via drag-and-drop#166
feat: import project files into a chat session via drag-and-drop#166selloriwoo wants to merge 39 commits into
Conversation
…rag handle - Enlarge the FileTypeIcon color badge (16→18px) so file type reads at a glance - Add a hover-reveal drag handle (grip) to signal rows are draggable - Move file size into the row tooltip; tighten spacing and hover states - Replace the borrowed .cw-artifact-row styling with dedicated .cw-sf-row
Drop a shared file from the Files page onto a session row in the left sidebar — it navigates to that session and attaches the file to the next message. - Sidebar session rows accept the dirent drag (highlight + drop) - Pass the scope-relative shared paths via router state (attachShared) - Session page reconstructs the global path (shared scope, same project) and imports them on arrival, then clears the state - Drop the text/plain payload from the sidebar file browser drag so external apps don't accept the drop
Folder-drop expansion could attach every file twice: dedupe/cap was computed against the closure's stale pendingAttachments while the append used the updater's prev. Move dedupe+cap into the updater so repeated/concurrent calls (StrictMode's double-invoked effect) stay idempotent.
…erriding --cw-selected-bg
Scrolling mid-drag let the rubber-band rectangle paint over the breadcrumb/ header outside the list. Clamp the drawn box to the scroll container (the hit-test still uses the full rect, so scrolled-off rows keep selecting), and drop the border on any clamped edge so it doesn't leave a stray line at the list boundary.
… sidebar & session) Both the sidebar browser drag and the Files→session attach effect expanded folders to their files with near-identical loops. Move it into a pure domain/files helper (folders recurse, .keep/dotfiles skipped, deduped).
| // the same files twice. | ||
| setPendingAttachments((prev) => { | ||
| const existing = new Set(prev.map((a) => a.globalPath).filter(Boolean)); | ||
| const toAdd = fresh.filter((it) => !existing.has(it.globalPath)); |
There was a problem hiding this comment.
The cap check moved out of the setPendingAttachments updater, so it now reads the stale closure pendingAttachments instead of the authoritative prev. Two imports that race before a re-render — most realistically the async attachShared effect path, which calls importSharedFiles after await fetchQuery — both read the same stale length, both pass the pre-check, and the updater only dedupes. Distinct-file batches can then push the total past MAX_ATTACHMENTS. Over the cap, send hits the backend 400, and since send() clears the composer/attachments before await, the draft is lost.
The pre-check above stays as the UX fast-path (early return + toast). Restore the hard clamp inside the updater so the invariant holds under the race — it's a no-op on the normal path (already <= MAX):
| const toAdd = fresh.filter((it) => !existing.has(it.globalPath)); | |
| const toAdd = fresh | |
| .filter((it) => !existing.has(it.globalPath)) | |
| .slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)); |
ljhh-0611
left a comment
There was a problem hiding this comment.
Out of this PR's strict scope, but the new backend attachment cap turns this pre-existing path into data loss:
handleFileSelectappends every picked file with no client-side cap (unlike the shared-import path, which caps at MAX_ATTACHMENTS).send()clearscomposerText/pendingAttachmentsbeforeawait sendMessage(...)and thecatchdoesn't restore them — so any send failure (network, 403, 423, and now the 400 from >30 attachments) drops the user's typed text and attachment tray.
Pick one (or both):
- Light (in this PR): cap
handleFileSelectlike the shared-import path, so the UI can't reach the backend 400. - Root (can be a follow-up): capture
pendingAttachmentsbefore clearing and restore it (withsetComposerText(text)) in thecatch, so drafts survive any failed send.
Not blocking — flagging because the new cap is what makes the >30 case reachable.
|
In my personal opinion, regarding the inclusion of more than 10 files in a message, I believe it is more appropriate to encourage the use of alternative methods such as the following; however, the actual user requirements seem to be the most important factor.
That said, blocking the inclusion of more than 10 files could lead to user inconvenience, and 30 seems to be an acceptable number in this regard. |
|
Applied the Light fix: handleFileSelect now caps at MAX_ATTACHMENTS (rejects the whole batch + toast, like the shared-import path) so the UI can't hit the backend 400. |
The shared useMarqueeSelection hook clamped the drawn rubber-band box to the scroll container — a regression from the original Files-page behavior where the box could roam the whole screen. Drop the clamp (and the now-unused MarqueeRect clamp fields / MarqueeOverlay border props); the hit-test already used the full rect, so selection is unchanged. Also track the post-marquee swallow-click listener in a ref and remove it on cleanup so it can't leak on window when the component unmounts before the next click. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Folders select on single click and enter on double click, matching the Files page (they were entering on a single click). - Replace the folder's right-side chevron with the count of immediate children, crossfading to a quick-add (+) button on hover/selection so a folder can be attached without opening it. - Drop the 6-dot grip (it reads as drag-to-reorder, which this list does not support); the row stays draggable with a grab cursor. - Surface the previously-unused shared_files.drag_hint caption under the breadcrumb to keep drag-to-attach discoverable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shared-source attachments showed a folder marker; a cloud reads more clearly as referenced-from-project-storage. Add a cloud glyph to the Icon set and use it for the shared marker in AttachmentChip and AttachmentPreview. Composer uploads keep the paperclip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The post-marquee swallow-click listener was being torn down by the [dragRect] effect cleanup (which re-runs on the setDragRect(null) in onUp), so the click following a marquee was no longer swallowed and cleared the selection when the cursor ended over the file view. Defer the swallow-listener cleanup to an unmount-only effect so it survives the gesture and still can't leak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sidebar shared-file browser: - shift+click range-selects (meta/ctrl toggles), matching the Files page - folder single-click selects, double-click enters; file double-click previews - drop the right-side child count / kebab — rows are just icon + name; the type icon swaps to a quick-add (+) on hover with a neutral border that deepens on hover Selection styling: - unify the selected-row fill as an accent wash via --cw-selected-bg(/-2), applied to the Files page too, with a distinct selected+hover step Pending attachment chips: - remove the x button; the whole chip is click-to-remove and turns red on hover to signal the destructive action Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up polish: shared-file panel, selection, and attachment chipsPushed a few rounds of refinement on top of the base PR ( Marquee (rubber-band) selection —
|
Overview
Adds an end-to-end flow for pulling project files into a chat session as attachments.
A shared-file browser lives in the session's right sidebar, and files/folders can be dragged from there (or from the Files page) to attach them to the next message.
Features
A bottom segmented toggle switches between Info ↔ Files; the active tab is accent-highlighted.
.keep/dotfiles).Selection keeps up while scrolling mid-drag; the box is clamped to the list bounds.
validate_attachments(with a unit test).Internal / refactor
useMarqueeSelectionhook (used by both the Files page and the sidebar browser).UX polish
Verification
tsc -bpassescargo checkpassesNotes
.keepis a placeholder the backend writes when an empty folder is created, so it's excluded from listings/attachments (consistent with the Files page).