Skip to content

feat: import project files into a chat session via drag-and-drop#166

Open
selloriwoo wants to merge 39 commits into
mainfrom
feat/file-import-session
Open

feat: import project files into a chat session via drag-and-drop#166
selloriwoo wants to merge 39 commits into
mainfrom
feat/file-import-session

Conversation

@selloriwoo

@selloriwoo selloriwoo commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

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

  1. Sidebar shared-file browser — browse the project's shared files in the right sidebar with folder navigation (breadcrumb, back, home + project name).
    A bottom segmented toggle switches between Info ↔ Files; the active tab is accent-highlighted.
  2. Drag-and-drop to attach
    • Sidebar browser → chat: adds to the next message's attachments (no upload — references the existing shared path).
    • Files page → a session row in the left nav: navigates to that session and attaches.
    • Dropping a folder expands it into the files it contains, recursively (skips .keep/dotfiles).
  3. Marquee multi-select + bulk drag — rubber-band select files and folders from empty space, then drag the whole selection at once.
    Selection keeps up while scrolling mid-drag; the box is clamped to the list bounds.
  4. Attachment cap — 30 on the frontend (overflow dropped with a toast) plus a server-side hard ceiling of 30 in validate_attachments (with a unit test).

Internal / refactor

  • Extracted the marquee selection logic into a shared useMarqueeSelection hook (used by both the Files page and the sidebar browser).
  • Dedupe/cap attachments inside the state updater so it's idempotent (prevents StrictMode double-attach).

UX polish

  • Sidebar rows: color-chip icons + drag handle; long names ellipsize.
  • Uniform accent drop-zone overlay on the chat while dragging (replaces the ragged text selection).
  • Fixed file-row selection fill (cleaned up conflicting legacy CSS).

Verification

  • Frontend tsc -b passes
  • Backend cargo check passes
  • Backend Attachment-cap unit test passes
  • Core drag-and-drop flows exercised manually in local dev

Notes

  • .keep is a placeholder the backend writes when an empty folder is created, so it's excluded from listings/attachments (consistent with the Files page).

…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
@selloriwoo selloriwoo linked an issue Jun 9, 2026 that may be closed by this pull request
selloriwoo added 14 commits June 9, 2026 18:29
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.
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.
@selloriwoo selloriwoo changed the title feat: file import to session feat: import project files into a chat session via drag-and-drop Jun 10, 2026
@selloriwoo selloriwoo self-assigned this Jun 10, 2026
@selloriwoo selloriwoo requested review from grf53 and ljhh-0611 June 10, 2026 04:37
@selloriwoo selloriwoo marked this pull request as ready for review June 10, 2026 04:38
@selloriwoo

selloriwoo commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up

Sidebar preview — double-click a file in the sidebar shared-file browser to open it in the in-app preview.

Over-cap imports — attaching >30 files now rejects the whole batch (was: kept first 30) with a toast. Files→session drops pre-check the count and stay put instead of navigating then rejecting.

Attachment source marker — left of the file icon: accent folder = shared-file reference, muted clip = upload. In both the composer and message history.
스크린샷 2026-06-11 오후 5 25 04

Comment thread app/src/routes/_app.projects.$projectSlug.sessions.$sessionPrefix.tsx Outdated
Comment thread app/src/routes/_app.projects.$projectSlug.sessions.$sessionPrefix.tsx Outdated
@selloriwoo selloriwoo requested a review from grf53 June 12, 2026 01:58
// 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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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):

Suggested change
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 ljhh-0611 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Out of this PR's strict scope, but the new backend attachment cap turns this pre-existing path into data loss:

  • handleFileSelect appends every picked file with no client-side cap (unlike the shared-import path, which caps at MAX_ATTACHMENTS).
  • send() clears composerText / pendingAttachments before await sendMessage(...) and the catch doesn'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 handleFileSelect like the shared-import path, so the UI can't reach the backend 400.
  • Root (can be a follow-up): capture pendingAttachments before clearing and restore it (with setComposerText(text)) in the catch, so drafts survive any failed send.

Not blocking — flagging because the new cap is what makes the >30 case reachable.

@grf53

grf53 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

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.

  • Encourage the AI ​​to directly find relevant files using methods such as glop, search (or grep) (~= encourage the use of Speedwagon first)
  • Encourage requests to be received by dividing them into multiple messages in stages.

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.

@selloriwoo

Copy link
Copy Markdown
Collaborator Author

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.

ljhh-0611 and others added 6 commits June 12, 2026 20:35
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>
@ljhh-0611

Copy link
Copy Markdown
Collaborator

Follow-up polish: shared-file panel, selection, and attachment chips

Pushed a few rounds of refinement on top of the base PR (642519b…f7272d2). What changed and why:

Marquee (rubber-band) selection — useMarqueeSelection

  • Restored the screen-wide selection box. It had been clamped to the list view; the hit-test already used the full rect, so only the drawn box had regressed vs main.
  • Fixed a listener leak — the post-marquee "swallow click" listener is now removed on unmount.
  • Fixed selection getting dropped on release. Releasing the marquee with the cursor over the file view cleared the selection: the swallow listener was being torn down by the [dragRect] effect cleanup before the click it needed to swallow. Cleanup is now deferred to an unmount-only effect, so it survives the gesture (and still can't leak).

Sidebar shared-file browser — SharedFilesPanel

  • shift+click now range-selects, meta/ctrl+click toggles — matching the Files page.
  • Folder rows: single-click selects, double-click enters. File rows: double-click previews.
  • Dropped the right-side child-count / kebab menu (felt noisy). Rows are just icon + name; the type icon swaps to a quick-add + on hover (neutral border that deepens on hover; the + glyph stays accent).
  • Surfaced the previously-unused shared_files.drag_hint caption under the breadcrumb, and removed the 6-dot grip (it read as drag-to-reorder, which this list doesn't do).

Selection color — cowork-design-system.css / globals.css

  • Unified the selected-row fill as an accent wash via --cw-selected-bg / --cw-selected-bg-2 (with a distinct selected+hover step), applied to the Files page rows/cards too.

Attachments

  • Shared-source attachments now use a cloud glyph (was a folder) to read as "from project storage"; composer uploads keep the paperclip.
  • Pending attachment chips: removed the × button — the whole chip is now click-to-remove and turns red on hover to signal the destructive action.

Frontend tsc -b passes; flows exercised in local dev (Vite).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[App] Files import to session

3 participants