Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8be0afe
feat(session): add shared-file browser to sidebar with drag-to-attach
selloriwoo Jun 8, 2026
1ceb476
fix(session): show ellipsis for long file names in sidebar browser
selloriwoo Jun 9, 2026
3f2a4f8
style(session): restyle sidebar shared-file rows with color chips + d…
selloriwoo Jun 9, 2026
f0345df
feat: drag a Files file onto a sidebar session to attach it
selloriwoo Jun 9, 2026
811c45c
feat: marquee multi-select + multi-file drag in shared file browser
selloriwoo Jun 9, 2026
3cffd12
refactor: replace sidebar toggle with Info/Files segmented control
selloriwoo Jun 9, 2026
c3e798f
fix: clear stray text selection after dropping a file on a session
selloriwoo Jun 9, 2026
bdea7d3
feat: expand dropped folders into files, capped at 20 attachments
selloriwoo Jun 9, 2026
fd9a80d
feat: cap message attachments at 30
selloriwoo Jun 9, 2026
abde507
Merge branch 'main' into feat/file-import-session
selloriwoo Jun 9, 2026
f9b8cd1
chore(session): align frontend attachment cap with backend (20 → 30)
selloriwoo Jun 9, 2026
183e3ff
fix: dedupe attachments inside the state updater to avoid doubles
selloriwoo Jun 9, 2026
0cee6a0
fix: replace ragged drag selection with a uniform chat drop-zone overlay
selloriwoo Jun 9, 2026
0103fde
fix: select marquee rows that scroll by during a drag (sidebar + Files)
selloriwoo Jun 9, 2026
d9f0550
refactor: extract shared useMarqueeSelection hook (Files + sidebar br…
selloriwoo Jun 10, 2026
be407ea
fix(files): selected file rows weren't filling — drop legacy rules ov…
selloriwoo Jun 10, 2026
6a15922
fix: clamp the selection box to the list bounds
selloriwoo Jun 10, 2026
3be667b
style: highlight active Info/Files tab with accent for discoverability
selloriwoo Jun 10, 2026
6dc18aa
Merge branch 'main' into feat/file-import-session
selloriwoo Jun 10, 2026
f121513
feat: drag folders & folder-inclusive multi-select to attach (sidebar)
selloriwoo Jun 10, 2026
dd83b1a
fix: hide .keep/dotfiles in sidebar browser (list + folder expand)
selloriwoo Jun 10, 2026
96525a9
fix: allow dragging empty folders (no-op drop) instead of blocking
selloriwoo Jun 10, 2026
d3fc099
fix: skip .keep/dotfiles when expanding a dropped folder (Files → ses…
selloriwoo Jun 10, 2026
5b69e47
fix: slide sidebar views in the direction of the tapped tab
selloriwoo Jun 10, 2026
9fecfcb
feat: preview sidebar files on double-click
selloriwoo Jun 10, 2026
c2d8238
refactor: extract expandDirentPaths helper (folder → files, shared by…
selloriwoo Jun 10, 2026
1c6d39d
refactor: extract MarqueeOverlay component
selloriwoo Jun 10, 2026
88dcc95
fix: reject over-cap attachment imports wholesale, before navigating
selloriwoo Jun 11, 2026
671726d
chore: remove dead .cw-sf-grip-spacer selector
selloriwoo Jun 11, 2026
41e76c9
feat: mark attachment source (folder = shared file, clip = upload)
selloriwoo Jun 11, 2026
28e6ffc
fix: inert the off-screen sidebar view
selloriwoo Jun 12, 2026
4b49cb8
Merge branch 'main' into feat/file-import-session
selloriwoo Jun 12, 2026
b85ad0d
fix: cap clip-button uploads at MAX_ATTACHMENTS
selloriwoo Jun 12, 2026
60036b7
fix(session): restore screen-wide marquee box and clean up its listener
ljhh-0611 Jun 12, 2026
f0e6dd4
feat(session): refine the sidebar shared-file rows
ljhh-0611 Jun 12, 2026
03fe500
feat(session): mark shared-file attachments with a cloud icon
ljhh-0611 Jun 12, 2026
f4dfb5b
fix(session): keep marquee selection on release
ljhh-0611 Jun 12, 2026
fe88aac
style(session): use a clearer cloud glyph for shared attachments
ljhh-0611 Jun 12, 2026
f7272d2
feat(session): refine shared-file panel & attachment chips
ljhh-0611 Jun 12, 2026
9427642
fix: suppress page-wide text selection during marquee drag
selloriwoo Jun 15, 2026
9886c45
fix: make the shared-file quick-add (+) ignore clicks while hidden
selloriwoo Jun 15, 2026
c94e986
fix(session): keep a persistent x on attachment chips
ljhh-0611 Jun 16, 2026
d217a45
refactor(session): drop the dead cw-sf-dir class
ljhh-0611 Jun 16, 2026
4ad2185
Apply suggestion from @ljhh-0611
ljhh-0611 Jun 16, 2026
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
15 changes: 13 additions & 2 deletions app/src/components/AttachmentChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ import { FileTypeIcon } from './FileTypeIcon';
interface Props {
filename: string;
status: 'uploading' | 'uploaded' | 'error';
/** True for files referenced from the project's shared files (vs. uploaded
* via the clip). Shows a small marker next to the file-type icon. */
shared?: boolean;
error?: string;
onRemove: () => void;
}

export function AttachmentChip({ filename, status, error, onRemove }: Props) {
export function AttachmentChip({ filename, status, shared, error, onRemove }: Props) {
return (
<div className="cw-attach-chip" title={error}>
{status === 'uploading' && <span className="cw-attach-spinner">⏳</span>}
{status === 'error' && <span className="cw-attach-error"><Icon name="x" size={11} /></span>}
{status === 'uploaded' && <FileTypeIcon filename={filename} size={14} />}
{status === 'uploaded' && (
<>
{/* Source marker: folder = referenced from shared files, clip = uploaded. */}
<span className={`cw-attach-source cw-attach-source--${shared ? 'shared' : 'upload'}`}>
<Icon name={shared ? 'folder' : 'paperclip'} size={11} />
</span>
<FileTypeIcon filename={filename} size={14} />
</>
)}
<span className="cw-attach-name">{filename}</span>
<button type="button" aria-label="remove" onClick={onRemove} className="cw-attach-remove">
<Icon name="x" size={11} />
Expand Down
5 changes: 5 additions & 0 deletions app/src/components/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function AttachmentPreview({ globalPath, onCopyToShared }: Props) {

const parsed = parseGlobalPath(globalPath);
const canCopy = Boolean(onCopyToShared && parsed);
const isShared = parsed?.scope.kind === 'shared';

function handleDownload() {
void downloadFileByGlobalPath(globalPath);
Expand All @@ -51,6 +52,10 @@ export function AttachmentPreview({ globalPath, onCopyToShared }: Props) {
onClick={() => setMenuOpen((prev) => !prev)}
title={filename}
>
{/* Source marker: folder = referenced from shared files, clip = uploaded. */}
<span className={`cw-attach-source cw-attach-source--${isShared ? 'shared' : 'upload'}`}>
<Icon name={isShared ? 'folder' : 'paperclip'} size={12} />
</span>
<FileTypeIcon filename={filename} size={16} />
<span className="cw-attach-name">{filename}</span>
{menuOpen && (
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/MarqueeOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createPortal } from 'react-dom';
import type { MarqueeRect } from '@/lib/useMarqueeSelection';

/**
* Renders the rubber-band rectangle produced by {@link useMarqueeSelection}.
* Portaled to <body> so a transformed / overflow-hidden ancestor can't re-anchor
* or clip the fixed overlay. The border on any clamped edge is dropped so a
* clamped side doesn't draw a stray line at the scroll container's boundary.
*/
export function MarqueeOverlay({ rect }: { rect: MarqueeRect | null }) {
if (!rect) return null;
return createPortal(
<div
className="cw-marquee"
style={{
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
borderTopWidth: rect.clampTop ? 0 : undefined,
borderBottomWidth: rect.clampBottom ? 0 : undefined,
borderLeftWidth: rect.clampLeft ? 0 : undefined,
borderRightWidth: rect.clampRight ? 0 : undefined,
}}
/>,
document.body,
);
}
238 changes: 238 additions & 0 deletions app/src/components/SharedFilesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// SharedFilesBrowser — a Files-page-style browser that takes over the session
// sidebar. Opened from the sidebar's "browse" button; lets the user navigate the
// project's shared folder and drag files onto the chat surface to attach them to
// the next message (see SESSION_IMPORT_MIME consumer in the session route).

import { useEffect, useMemo, useRef, useState } from 'react';
import { MarqueeOverlay } from '@/components/MarqueeOverlay';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { listDirentsRaw, stripScopePrefix, type DirentScope } from '@/api/dirents';
import { expandDirentPaths, isHiddenName, nameOf } from '@/domain/files';
import { FilePreviewModal } from '@/components/FilePreviewModal';
import { useMarqueeSelection } from '@/lib/useMarqueeSelection';
import { FileTypeIcon } from './FileTypeIcon';
import { Icon } from './Icon';
import { EmptyState } from './uiPrimitives';

/** dataTransfer MIME for dragging a shared file into the session as an attachment. */
export const SESSION_IMPORT_MIME = 'application/x-cowork-session-import';

/** Payload carried on SESSION_IMPORT_MIME — a list of files to attach. */
export type SessionImportItem = { globalPath: string; filename: string };

interface SharedFilesBrowserProps {
projectId: string;
/** Project name, shown as the breadcrumb root next to the home icon. */
projectName?: string;
/** Import the given shared files into the session (inline button fallback). */
onImport: (items: SessionImportItem[]) => void;
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

type Row =
| { kind: 'dir'; name: string; relPath: string; globalPath: string }
| { kind: 'file'; name: string; relPath: string; globalPath: string; bytes?: number | null };

export function SharedFilesBrowser({ projectId, projectName, onImport }: SharedFilesBrowserProps) {
const { t } = useTranslation('session');
const scope: DirentScope = { kind: 'shared', projectId };

// One recursive fetch; folder navigation is done in-memory by relative depth.
const { data: rawEntries = [], isLoading } = useQuery({
queryKey: ['dirents', 'shared', projectId],
queryFn: () => listDirentsRaw(scope, true),
enabled: Boolean(projectId),
});

// current directory, relative to the shared root ('' = root)
const [dir, setDir] = useState('');
// Double-clicked file shown in the in-app preview modal (global path).
const [previewPath, setPreviewPath] = useState<string | null>(null);

// Immediate children of `dir`: entries one level below the current path.
const rows = useMemo<Row[]>(() => {
const prefix = dir ? `${dir}/` : '';
const out: Row[] = [];
for (const e of rawEntries) {
const rel = stripScopePrefix(scope, e.path);
if (!rel.startsWith(prefix)) continue;
const remainder = rel.slice(prefix.length);
if (!remainder || remainder.includes('/')) continue; // not an immediate child
if (isHiddenName(remainder)) continue; // hide dotfiles (.keep placeholders, etc.)
if (e.kind === 'dir') {
out.push({ kind: 'dir', name: remainder, relPath: rel, globalPath: e.path });
} else {
out.push({ kind: 'file', name: nameOf(e), relPath: rel, globalPath: e.path, bytes: e.bytes });
}
}
// folders first, then files; alphabetical within each group
out.sort((a, b) =>
a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1,
);
return out;
// scope is derived from projectId, safe to omit
}, [rawEntries, dir, projectId]);

const segments = dir ? dir.split('/') : [];

// ── multi-select (rubber-band marquee + click) ───────────────────
// Selected file global paths, per-view (cleared on folder change). Filenames
// for the drag payload are derived from each path's basename.
const [selected, setSelected] = useState<Set<string>>(new Set());
const listRef = useRef<HTMLDivElement>(null);

// Selection is per-view — reset when changing folders.
useEffect(() => { setSelected(new Set()); }, [dir]);

const marquee = useMarqueeSelection({
scrollRef: listRef,
itemSelector: '[data-sf-path]',
keyAttr: 'sfPath',
ignoreSelector: '.cw-sf-add', // let the inline + button work
getSelection: () => selected,
setSelection: setSelected,
});

function selectClick(e: React.MouseEvent, globalPath: string) {
const additive = e.shiftKey || e.metaKey || e.ctrlKey;
setSelected((prev) => {
if (!additive) return new Set([globalPath]);
const next = new Set(prev);
if (next.has(globalPath)) next.delete(globalPath);
else next.add(globalPath);
return next;
});
}

// One drag handler for both file and folder rows. Dragging a selected row
// carries the whole selection (folders expanded to their files); otherwise
// just the dragged row.
function handleRowDragStart(e: React.DragEvent, globalPath: string) {
marquee.cancel(); // a native drag is starting — abort any pending marquee
const sources = selected.has(globalPath) && selected.size > 1 ? [...selected] : [globalPath];
const items = expandDirentPaths(rawEntries, sources);
// An empty folder still drags (drop is just a no-op) — don't block the gesture.
// Custom MIME only — omit text/plain so external apps can't accept the drop.
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData(SESSION_IMPORT_MIME, JSON.stringify(items));

const ghost = document.createElement('div');
ghost.className = 'cw-drag-ghost';
ghost.textContent = items.length === 1
? items[0]!.filename
: items.length === 0
? (globalPath.split('/').pop() ?? globalPath)
: t('shared_files.drag_items', { count: items.length });
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 14, 14);
requestAnimationFrame(() => ghost.remove());
}

return (
<div className="cw-files-browser">
{/* ── breadcrumb (the bottom switch handles returning to info) ─ */}
<div className="cw-files-breadcrumb">
{/* Always present (hidden at root) so navigating doesn't shift the row. */}
<button
type="button"
className="cw-files-up"
aria-label={t('shared_files.up')}
title={t('shared_files.up')}
disabled={!dir}
onClick={() => setDir(segments.slice(0, -1).join('/'))}
>
<Icon name="arrow-left" size={14} />
</button>
<button type="button" className="cw-files-home" onClick={() => setDir('')} disabled={!dir}>
<Icon name="home" size={12} />
{projectName && <span className="cw-files-home-name">{projectName}</span>}
</button>
{segments.map((seg, i) => {
const target = segments.slice(0, i + 1).join('/');
const isLast = i === segments.length - 1;
return (
<span key={target} className="cw-files-crumb">
<Icon name="chevron-right" size={11} />
<button type="button" onClick={() => setDir(target)} disabled={isLast}>{seg}</button>
</span>
);
})}
</div>

{/* ── rows ─────────────────────────────────────────────────── */}
<div className="cw-files-browser-list" ref={listRef} onMouseDown={marquee.onMouseDown}>
{rows.map((row) =>
row.kind === 'dir' ? (
<div
className={`cw-sf-row cw-sf-dir${selected.has(row.globalPath) ? ' is-selected' : ''}`}
key={row.relPath}
data-sf-path={row.globalPath}
data-sf-name={row.name}
draggable
onDragStart={(e) => handleRowDragStart(e, row.globalPath)}
onClick={() => setDir(row.relPath)}
>
<span className="cw-sf-grip" aria-hidden="true">
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
<circle cx="2" cy="2" r="1.2" /><circle cx="8" cy="2" r="1.2" />
<circle cx="2" cy="7" r="1.2" /><circle cx="8" cy="7" r="1.2" />
<circle cx="2" cy="12" r="1.2" /><circle cx="8" cy="12" r="1.2" />
</svg>
</span>
<Icon name="folder" size={18} />
<span className="cw-file-label">{row.name}</span>
<Icon name="chevron-right" size={14} />
</div>
) : (
<div
className={`cw-sf-row${selected.has(row.globalPath) ? ' is-selected' : ''}`}
key={row.relPath}
data-sf-path={row.globalPath}
data-sf-name={row.name}
draggable
onDragStart={(e) => handleRowDragStart(e, row.globalPath)}
onClick={(e) => selectClick(e, row.globalPath)}
onDoubleClick={() => setPreviewPath(row.globalPath)}
title={`${row.name}${row.bytes != null ? ` · ${formatBytes(row.bytes)}` : ''}`}
>
<span className="cw-sf-grip" aria-hidden="true">
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
<circle cx="2" cy="2" r="1.2" /><circle cx="8" cy="2" r="1.2" />
<circle cx="2" cy="7" r="1.2" /><circle cx="8" cy="7" r="1.2" />
<circle cx="2" cy="12" r="1.2" /><circle cx="8" cy="12" r="1.2" />
</svg>
</span>
<FileTypeIcon filename={row.name} size={18} />
<span className="cw-file-label">{row.name}</span>
<button
type="button"
className="cw-sf-add"
aria-label={t('shared_files.import')}
title={t('shared_files.import')}
onClick={(e) => { e.stopPropagation(); onImport([{ globalPath: row.globalPath, filename: row.name }]); }}
>
<Icon name="plus" size={13} />
</button>
</div>
),
)}

{!isLoading && rows.length === 0 && (
<EmptyState chip="📁" title={t('shared_files.empty_title')} body={t('shared_files.empty_body')} />
)}
</div>

<MarqueeOverlay rect={marquee.dragRect} />

{previewPath && (
<FilePreviewModal globalPath={previewPath} onClose={() => setPreviewPath(null)} />
)}
</div>
);
}
Loading