diff --git a/app/src/components/AttachmentChip.tsx b/app/src/components/AttachmentChip.tsx index cd45c4b7..37c3a34e 100644 --- a/app/src/components/AttachmentChip.tsx +++ b/app/src/components/AttachmentChip.tsx @@ -1,23 +1,46 @@ +import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; 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) { + const { t } = useTranslation('session'); + // The whole chip is click-to-remove. It shows a persistent × and reddens on hover so + // the destructive action is unmistakable (per PR review — colour alone read as ambiguous). + // The × is a visual hint, not a separate button; the chip itself handles the click. return ( -
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRemove(); } }} + > {status === 'uploading' && } {status === 'error' && } - {status === 'uploaded' && } + {status === 'uploaded' && ( + <> + {/* Source marker: cloud = referenced from shared files, clip = uploaded. */} + + + + + + )} {filename} - + {/* Persistent × hint — signals that clicking the chip removes it. */} +
); } diff --git a/app/src/components/AttachmentPreview.tsx b/app/src/components/AttachmentPreview.tsx index d0a0afe6..33ea659f 100644 --- a/app/src/components/AttachmentPreview.tsx +++ b/app/src/components/AttachmentPreview.tsx @@ -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); @@ -51,6 +52,10 @@ export function AttachmentPreview({ globalPath, onCopyToShared }: Props) { onClick={() => setMenuOpen((prev) => !prev)} title={filename} > + {/* Source marker: cloud = referenced from shared files, clip = uploaded. */} + + + {filename} {menuOpen && ( diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx index d47a0328..4ad59675 100644 --- a/app/src/components/Icon.tsx +++ b/app/src/components/Icon.tsx @@ -9,7 +9,7 @@ export type IconName = | 'recap' | 'more' | 'upload' | 'trash' | 'download' | 'copy' | 'list' | 'grid' | 'chevron-right' | 'chevron-left' | 'rotate-ccw' | 'corner-down-left' | 'paperclip' | 'file-pdf' | 'file-code' | 'file-archive' | 'file-video' | 'file-audio' - | 'pause' | 'play' | 'sticky-notes' | 'stop' | 'zoom-out' | 'log-out' | 'globe'; + | 'pause' | 'play' | 'sticky-notes' | 'stop' | 'zoom-out' | 'log-out' | 'globe' | 'cloud'; type PreviewIconName = | 'home' | 'folder' | 'folder-open' | 'users' | 'settings' | 'lock' @@ -20,7 +20,7 @@ type PreviewIconName = | 'more-horizontal' | 'trash-2' | 'download' | 'copy' | 'list' | 'grid-3x3' | 'chevron-right' | 'chevron-left' | 'rotate-ccw' | 'corner-down-left' | 'paperclip' | 'file-code' | 'file-archive' | 'file-video' | 'file-audio' | 'pause' | 'play' | 'sticky-notes' - | 'square' | 'zoom-out' | 'log-out' | 'globe'; + | 'square' | 'zoom-out' | 'log-out' | 'globe' | 'cloud'; const previewIconPath: Record = { lock: '', @@ -77,6 +77,7 @@ const previewIconPath: Record = { 'zoom-out': '', 'log-out': '', globe: '', + cloud: '', }; const iconAlias: Record = { @@ -137,6 +138,7 @@ const iconAlias: Record = { 'zoom-out': 'zoom-out', 'log-out': 'log-out', globe: 'globe', + cloud: 'cloud', }; export function Icon({ name, size = 16, className = '', strokeWidth = 2, style, ...props }: SVGProps & { name: IconName; size?: number }) { diff --git a/app/src/components/MarqueeOverlay.tsx b/app/src/components/MarqueeOverlay.tsx new file mode 100644 index 00000000..8b0bb5f8 --- /dev/null +++ b/app/src/components/MarqueeOverlay.tsx @@ -0,0 +1,23 @@ +import { createPortal } from 'react-dom'; +import type { MarqueeRect } from '@/lib/useMarqueeSelection'; + +/** + * Renders the rubber-band rectangle produced by {@link useMarqueeSelection}. + * Portaled to so a transformed / overflow-hidden ancestor can't re-anchor + * or clip the fixed overlay, and so the box can extend across the whole screen. + */ +export function MarqueeOverlay({ rect }: { rect: MarqueeRect | null }) { + if (!rect) return null; + return createPortal( +
, + document.body, + ); +} diff --git a/app/src/components/SharedFilesPanel.tsx b/app/src/components/SharedFilesPanel.tsx new file mode 100644 index 00000000..7260e0f2 --- /dev/null +++ b/app/src/components/SharedFilesPanel.tsx @@ -0,0 +1,262 @@ +// 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(''); + // File shown in the in-app preview modal (global path), opened by double-clicking a file row. + const [previewPath, setPreviewPath] = useState(null); + + // Immediate children of `dir`: entries one level below the current path. + const rows = useMemo(() => { + 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>(new Set()); + const listRef = useRef(null); + // Pivot for shift+click range selection (a global path in the current view). + const anchorRef = useRef(null); + + // Selection is per-view — reset when changing folders. + useEffect(() => { setSelected(new Set()); anchorRef.current = null; }, [dir]); + + const marquee = useMarqueeSelection({ + scrollRef: listRef, + itemSelector: '[data-sf-path]', + keyAttr: 'sfPath', + ignoreSelector: '.cw-sf-icon-add', // don't start a marquee on the row's + button + getSelection: () => selected, + setSelection: setSelected, + onClear: () => { setSelected(new Set()); anchorRef.current = null; }, + }); + + function selectClick(e: React.MouseEvent, globalPath: string) { + // shift = range from the anchor; meta/ctrl = toggle one; plain = select one. + if (e.shiftKey && anchorRef.current) { + const order = rows.map((r) => r.globalPath); + const a = order.indexOf(anchorRef.current); + const b = order.indexOf(globalPath); + if (a !== -1 && b !== -1) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + setSelected(new Set(order.slice(lo, hi + 1))); + return; + } + } + if (e.metaKey || e.ctrlKey) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(globalPath)) next.delete(globalPath); + else next.add(globalPath); + return next; + }); + anchorRef.current = globalPath; + return; + } + setSelected(new Set([globalPath])); + anchorRef.current = globalPath; + } + + // 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 ( +
+ {/* ── breadcrumb (the bottom switch handles returning to info) ─ */} +
+ {/* Always present (hidden at root) so navigating doesn't shift the row. */} + + + {segments.map((seg, i) => { + const target = segments.slice(0, i + 1).join('/'); + const isLast = i === segments.length - 1; + return ( + + + + + ); + })} +
+ + {/* Drag affordance hint — rows can be dragged into the chat to attach. */} +

{t('shared_files.drag_hint')}

+ + {/* ── rows ─────────────────────────────────────────────────── */} +
+ {rows.map((row) => + row.kind === 'dir' ? ( +
handleRowDragStart(e, row.globalPath)} + onClick={(e) => selectClick(e, row.globalPath)} + onDoubleClick={() => setDir(row.relPath)} + > + {/* Type icon swaps to a quick-add (+) on hover; + attaches the folder's files. */} + + + + + {row.name} +
+ ) : ( +
handleRowDragStart(e, row.globalPath)} + onClick={(e) => selectClick(e, row.globalPath)} + onDoubleClick={() => setPreviewPath(row.globalPath)} + title={`${row.name}${row.bytes != null ? ` · ${formatBytes(row.bytes)}` : ''}`} + > + {/* Type icon swaps to a quick-add (+) on hover; double-click previews the file. */} + + + + + {row.name} +
+ ), + )} + + {!isLoading && rows.length === 0 && ( + + )} +
+ + + + {previewPath && ( + setPreviewPath(null)} /> + )} +
+ ); +} diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 942f96cf..9fbf7322 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -10,9 +10,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import logoMark from '@/assets/logo-mark.svg'; import { listProjects } from '@/api/projects'; +import { scopeRoot } from '@/api/dirents'; +import type { BackendDirent } from '@/api/backend-types'; +import { expandDirentPaths, MAX_ATTACHMENTS } from '@/domain/files'; import { listSessions, markSessionRead } from '@/api/sessions'; import { Icon } from '@/components/Icon'; import { Avatar, IconPocket } from '@/components/uiPrimitives'; +import { useToastStore } from '@/components/Toast'; import { useAuthStore } from '@/stores/auth'; import { getSidebarModeForDrag, @@ -244,7 +248,52 @@ export function Sidebar() { }); const queryClient = useQueryClient(); + const showToast = useToastStore((s) => s.show); const [streamingIds, setStreamingIds] = useState>(new Set()); + // Session row currently under a file drag (from the Files page) — highlights + // the drop target. Dropping navigates to the session and attaches the file. + const [dropSessionId, setDropSessionId] = useState(null); + + // Accept files dragged from the Files page onto a session row: navigate to + // that session, handing the (scope-relative) shared paths via router state so + // the session page attaches them to the next message. + const FILE_DRAG_MIME = 'application/x-cowork-dirent-paths'; + function handleSessionDrop(e: React.DragEvent, projectSlug: string, sessionId: string) { + setDropSessionId(null); + const raw = e.dataTransfer.getData(FILE_DRAG_MIME); + if (!raw) return; + e.preventDefault(); + let paths: string[]; + try { paths = JSON.parse(raw); } catch { return; } + if (!Array.isArray(paths) || paths.length === 0) return; + + // Pre-check the attachment cap so we don't navigate for an import the session + // would just reject. Folders expand to their files via the cached shared + // listing (raw/global paths). Skipped if the listing isn't cached — the + // session page still enforces the cap as a fallback. + const projectId = activeProject?.id; + if (projectId) { + const entries = queryClient.getQueryData(['dirents', 'shared', projectId]); + if (entries) { + const root = scopeRoot({ kind: 'shared', projectId }); + const count = expandDirentPaths(entries, paths.map((rel) => `${root}/${rel}`)).length; + if (count > MAX_ATTACHMENTS) { + showToast(t('attach_limit', { max: MAX_ATTACHMENTS })); + return; // over the cap — stay on this page, attach nothing + } + } + } + + // A drag can leave a stray text selection highlighted; clear it before we + // navigate away (otherwise it stays stuck on the next page). Folders are + // expanded into their contained files by the session page. + window.getSelection()?.removeAllRanges(); + navigate({ + to: '/projects/$projectSlug/sessions/$sessionPrefix', + params: { projectSlug, sessionPrefix: shortSessionId(sessionId) }, + state: { attachShared: paths }, + }); + } const activeProjectSlugRef = useRef(null); useEffect(() => { activeProjectSlugRef.current = activeProjectSlug; @@ -500,11 +549,23 @@ export function Sidebar() { shortSessionId(session.id) === activeSessionId ? 'is-active' : '', session.unreadCount > 0 ? 'is-unread' : '', streamingIds.has(session.id) ? 'is-streaming' : '', + dropSessionId === session.id ? 'is-drop-target' : '', ].filter(Boolean).join(' ')} onClick={() => openSession(activeProject.slug, shortSessionId(session.id))} role="button" tabIndex={0} style={{ cursor: 'pointer' }} + onDragOver={(e) => { + if (!e.dataTransfer.types.includes(FILE_DRAG_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + if (dropSessionId !== session.id) setDropSessionId(session.id); + }} + onDragLeave={(e) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setDropSessionId((cur) => (cur === session.id ? null : cur)); + }} + onDrop={(e) => handleSessionDrop(e, activeProject.slug, session.id)} > {streamingIds.has(session.id) ? ( diff --git a/app/src/domain/files.ts b/app/src/domain/files.ts index e263f8b1..e62aa607 100644 --- a/app/src/domain/files.ts +++ b/app/src/domain/files.ts @@ -27,6 +27,41 @@ export function nameOf(entry: BackendDirent): string { return parts[parts.length - 1] ?? entry.path; } +/** Max files attachable to one message. Mirrors the backend's server-side + * ceiling in `validate_attachments`; an import that would exceed it is rejected + * wholesale (frontend) and 400'd (backend). */ +export const MAX_ATTACHMENTS = 30; + +export interface ExpandedFile { globalPath: string; filename: string; } + +/** + * Expand dirent global paths to the files they cover: a folder yields its + * descendant files (recursive), a file yields itself. Hidden names (.keep and + * other dotfiles) are skipped and results are deduped by global path. + * `entries` must be the recursive listing the paths come from. + */ +export function expandDirentPaths(entries: BackendDirent[], globalPaths: Iterable): ExpandedFile[] { + const out: ExpandedFile[] = []; + const seen = new Set(); + const add = (gp: string) => { + if (seen.has(gp)) return; + seen.add(gp); + out.push({ globalPath: gp, filename: gp.split('/').filter(Boolean).pop() ?? gp }); + }; + for (const p of globalPaths) { + const entry = entries.find((e) => e.path === p); + if (entry?.kind === 'dir') { + const prefix = `${p}/`; + for (const e of entries) { + if (e.kind === 'file' && e.path.startsWith(prefix) && !isHiddenName(nameOf(e))) add(e.path); + } + } else { + add(p); + } + } + return out; +} + // Returns entries that live one level directly under `pathSegments`. // Excludes the directory's own row and dotfiles (.keep etc.). export interface DirectChildren { diff --git a/app/src/i18n/locales/en/common.json b/app/src/i18n/locales/en/common.json index aef1a120..b616e519 100644 --- a/app/src/i18n/locales/en/common.json +++ b/app/src/i18n/locales/en/common.json @@ -1,4 +1,5 @@ { + "attach_limit": "You can attach up to {{max}} files at once.", "actions": { "save": "Save", "cancel": "Cancel", diff --git a/app/src/i18n/locales/en/session.json b/app/src/i18n/locales/en/session.json index ff6523b2..fba9c9e9 100644 --- a/app/src/i18n/locales/en/session.json +++ b/app/src/i18n/locales/en/session.json @@ -42,9 +42,22 @@ "no_pinned_files": "No pinned files yet.", "access": "Access", "session": "Session", - "project_label": "project · {{name}}" + "project_label": "project · {{name}}", + "info_tab": "Info", + "files_tab": "Files" }, "artifacts_header": "Artifacts", + "shared_files_header": "Shared files", + "shared_files": { + "import": "Add to message", + "remove": "Remove", + "up": "Back", + "drag_hint": "Drag into the chat to attach", + "attach_limit": "You can attach up to {{max}} files at once.", + "drag_items": "{{count}} files", + "empty_title": "No shared files", + "empty_body": "Project shared files appear here. Drag one into the chat to attach it." + }, "menu": { "options": "Session options", "mark_read": "Mark as read", diff --git a/app/src/i18n/locales/ko/common.json b/app/src/i18n/locales/ko/common.json index 8f4e2ee9..4dc9fa45 100644 --- a/app/src/i18n/locales/ko/common.json +++ b/app/src/i18n/locales/ko/common.json @@ -1,4 +1,5 @@ { + "attach_limit": "한 번에 최대 {{max}}개까지 첨부할 수 있어요.", "actions": { "save": "저장", "cancel": "취소", diff --git a/app/src/i18n/locales/ko/session.json b/app/src/i18n/locales/ko/session.json index 5e66be59..ba91080c 100644 --- a/app/src/i18n/locales/ko/session.json +++ b/app/src/i18n/locales/ko/session.json @@ -42,9 +42,22 @@ "no_pinned_files": "고정된 파일이 아직 없어요.", "access": "접근", "session": "세션", - "project_label": "프로젝트 · {{name}}" + "project_label": "프로젝트 · {{name}}", + "info_tab": "정보", + "files_tab": "파일" }, "artifacts_header": "산출물", + "shared_files_header": "공유 파일", + "shared_files": { + "import": "메시지에 추가", + "remove": "제거", + "up": "뒤로가기", + "drag_hint": "채팅으로 끌어다 놓으면 첨부돼요", + "attach_limit": "한 번에 최대 {{max}}개까지 첨부할 수 있어요.", + "drag_items": "파일 {{count}}개", + "empty_title": "공유 파일이 없어요", + "empty_body": "프로젝트 공유 파일이 여기에 표시돼요. 채팅으로 끌어다 놓으면 첨부돼요." + }, "menu": { "options": "세션 옵션", "mark_read": "읽음으로 표시", diff --git a/app/src/lib/useMarqueeSelection.ts b/app/src/lib/useMarqueeSelection.ts new file mode 100644 index 00000000..feae9aec --- /dev/null +++ b/app/src/lib/useMarqueeSelection.ts @@ -0,0 +1,180 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface MarqueeRect { + left: number; top: number; width: number; height: number; +} + +interface UseMarqueeSelectionOpts { + /** Scroll container. The anchor is pinned to its content, so scrolling mid-drag + * keeps selecting the rows that scroll past. */ + scrollRef: React.RefObject; + /** CSS selector for one selectable row — used both to detect a press that lands + * on a row and to enumerate rows for hit-testing. */ + itemSelector: string; + /** dataset key holding a row's selection key (e.g. 'sfPath' for data-sf-path). */ + keyAttr: string; + /** Current selection, read once at press to seed an additive / row-start drag. */ + getSelection: () => Set; + /** Replace the selection (called as the marquee sweeps). */ + setSelection: (next: Set) => void; + /** Pressing empty space with no modifier; defaults to clearing the selection. + * Provide this when clearing must also reset other state (e.g. a shift anchor). */ + onClear?: () => void; + /** Elements that must NOT start a marquee (e.g. an inline action button). */ + ignoreSelector?: string; + /** Pixels of movement before a press becomes a drag. */ + threshold?: number; +} + +/** + * Rubber-band (marquee) multi-selection for a scrollable list of rows. The caller + * owns the selection state; this hook wires the press/drag/scroll/release handlers + * and reports the rectangle to draw. Returns `cancel` so a native HTML5 drag that + * starts on a row can abort a pending marquee. + */ +export function useMarqueeSelection(opts: UseMarqueeSelectionOpts) { + const optsRef = useRef(opts); + optsRef.current = opts; + + const [dragRect, setDragRect] = useState(null); + const originRef = useRef<{ x: number; y: number; base: Set } | null>(null); + const didDragRef = useRef(false); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const lastScrollTopRef = useRef(0); + // The capture-phase click listener onUp installs to swallow the post-marquee + // click. Tracked here so the effect cleanup can remove it if the component + // unmounts before that click arrives (otherwise it leaks on window). + const swallowRef = useRef<((ev: Event) => void) | null>(null); + + function onMouseDown(e: React.MouseEvent) { + if (e.button !== 0) return; + const o = optsRef.current; + const target = e.target as HTMLElement; + if (o.ignoreSelector && target.closest(o.ignoreSelector)) return; + const onItem = !!target.closest(o.itemSelector); + const additive = e.shiftKey || e.metaKey || e.ctrlKey; + originRef.current = { + x: e.clientX, + y: e.clientY, + base: onItem || additive ? new Set(o.getSelection()) : new Set(), + }; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + lastScrollTopRef.current = o.scrollRef.current?.scrollTop ?? 0; + didDragRef.current = false; + if (!onItem && !additive) { + if (o.onClear) o.onClear(); + else o.setSelection(new Set()); + } + } + + // While a marquee is dragging, suppress text selection page-wide. The box is + // portaled to and roams the whole screen, so a list-only `user-select: + // none` doesn't stop the drag from selecting text it sweeps over (chat, etc.). + function lockSelection() { + document.body.style.setProperty('user-select', 'none'); + document.body.style.setProperty('-webkit-user-select', 'none'); + } + function unlockSelection() { + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + } + + function cancel() { + originRef.current = null; + setDragRect(null); + unlockSelection(); + } + + useEffect(() => { + function apply(cx: number, cy: number) { + const origin = originRef.current; + if (!origin) return; + const o = optsRef.current; + const threshold = o.threshold ?? 4; + const dx = cx - origin.x; + const dy = cy - origin.y; + if (Math.abs(dx) < threshold && Math.abs(dy) < threshold && !dragRect) return; + if (!didDragRef.current) { + didDragRef.current = true; + // Just crossed the threshold — this is a marquee, not a click. Lock + // selection and drop any text range that formed before we knew. + lockSelection(); + window.getSelection()?.removeAllRanges(); + } + const left = Math.min(origin.x, cx); + const top = Math.min(origin.y, cy); + const width = Math.abs(dx); + const height = Math.abs(dy); + const r = { left, top, right: left + width, bottom: top + height }; + // Draw the full rect (portaled to ); it can roam the whole screen, + // matching the original Files-page behavior. The hit-test below uses the + // same rect, so rows scrolled out of the list still select. + setDragRect({ left, top, width, height }); + const next = new Set(origin.base); + for (const el of document.querySelectorAll(o.itemSelector)) { + const rect = el.getBoundingClientRect(); + if (rect.left < r.right && rect.right > r.left && rect.top < r.bottom && rect.bottom > r.top) { + const key = el.dataset[o.keyAttr]; + if (key) next.add(key); + } + } + o.setSelection(next); + } + function onMove(e: MouseEvent) { + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + apply(e.clientX, e.clientY); + } + // Scrolling mid-drag: the anchor is pinned to content, so shift it by the + // scroll delta and re-test (a wheel scroll fires no mousemove). + function onScroll() { + const origin = originRef.current; + const sc = optsRef.current.scrollRef.current; + if (!origin || !sc) return; + const delta = sc.scrollTop - lastScrollTopRef.current; + lastScrollTopRef.current = sc.scrollTop; + origin.y -= delta; + apply(lastPointerRef.current.x, lastPointerRef.current.y); + } + function onUp() { + unlockSelection(); + const dragged = didDragRef.current && originRef.current; + originRef.current = null; + setDragRect(null); + if (dragged) { + // Swallow the click that follows a marquee so it doesn't reset selection. + const swallow = (ev: Event) => { + ev.stopPropagation(); + ev.preventDefault(); + window.removeEventListener('click', swallow, true); + swallowRef.current = null; + }; + swallowRef.current = swallow; + window.addEventListener('click', swallow, true); + } + } + const sc = optsRef.current.scrollRef.current; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + sc?.addEventListener('scroll', onScroll); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + sc?.removeEventListener('scroll', onScroll); + }; + }, [dragRect]); + + // Remove a still-pending post-marquee swallow listener on unmount only. The + // effect above re-runs on every dragRect change — including the setDragRect(null) + // in onUp — so its cleanup must NOT touch the swallow, or the listener gets torn + // down before the click it exists to swallow, and that click clears the selection. + useEffect(() => () => { + if (swallowRef.current) { + window.removeEventListener('click', swallowRef.current, true); + swallowRef.current = null; + } + // Unmounting mid-drag must not leave the page un-selectable. + unlockSelection(); + }, []); + + return { onMouseDown, dragRect, cancel }; +} diff --git a/app/src/main.tsx b/app/src/main.tsx index 3ab38157..13feaa48 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -49,6 +49,9 @@ declare module '@tanstack/react-router' { focusComposer?: boolean; // Agent selected in the home composer, carried to the session header chip. initialAgentId?: import('@/domain/agentSurfaces').AgentId; + // Shared-file paths (scope-relative) dragged from Files onto a session row, + // attached to the next message once the target session resolves. + attachShared?: string[]; } } diff --git a/app/src/routes/_app.projects.$projectSlug.files.tsx b/app/src/routes/_app.projects.$projectSlug.files.tsx index 1bb1c50b..10bed872 100644 --- a/app/src/routes/_app.projects.$projectSlug.files.tsx +++ b/app/src/routes/_app.projects.$projectSlug.files.tsx @@ -11,7 +11,7 @@ // drag empty area — rubber-band rectangle select import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { MarqueeOverlay } from '@/components/MarqueeOverlay'; import { createFileRoute } from '@tanstack/react-router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -51,6 +51,7 @@ import { type FolderNode, } from '@/domain/files'; import type { BackendDirent } from '@/api/backend-types'; +import { useMarqueeSelection } from '@/lib/useMarqueeSelection'; type ViewMode = 'list' | 'grid'; const VIEW_KEY = 'cowork.files.viewMode'; @@ -509,80 +510,15 @@ function FilesPage() { // ── Rubber-band selection ──────────────────────────────────────── const bodyRef = useRef(null); - const dragOriginRef = useRef<{ - x: number; - y: number; - basePaths: Set; - startedOnRow: boolean; - additive: boolean; - } | null>(null); - const didDragRef = useRef(false); - const [dragRect, setDragRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); - - const onBodyMouseDown = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return; - const rowEl = (e.target as HTMLElement).closest('[data-row-index]'); - const additive = e.shiftKey || e.metaKey || e.ctrlKey; - dragOriginRef.current = { - x: e.clientX, - y: e.clientY, - basePaths: rowEl || additive ? new Set(selectedPaths) : new Set(), - startedOnRow: !!rowEl, - additive, - }; - didDragRef.current = false; - if (!rowEl && !additive) clearSelection(); - }, [selectedPaths, clearSelection]); - - useEffect(() => { - function onMove(e: MouseEvent) { - const origin = dragOriginRef.current; - if (!origin) return; - const dx = e.clientX - origin.x; - const dy = e.clientY - origin.y; - if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD && !dragRect) return; - didDragRef.current = true; - - const left = Math.min(origin.x, e.clientX); - const top = Math.min(origin.y, e.clientY); - const width = Math.abs(dx); - const height = Math.abs(dy); - setDragRect({ left, top, width, height }); - - const rows = Array.from(document.querySelectorAll('[data-row-index]')); - const next = new Set(origin.basePaths); - const r = { left, top, right: left + width, bottom: top + height }; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const inside = rect.left < r.right && rect.right > r.left && rect.top < r.bottom && rect.bottom > r.top; - if (inside) { - const path = row.dataset.rowPath; - if (path) next.add(path); - } - } - setSelectedPaths(next); - } - function onUp() { - const origin = dragOriginRef.current; - dragOriginRef.current = null; - setDragRect(null); - if (!origin) return; - if (didDragRef.current) { - const swallow = (ev: Event) => { - ev.stopPropagation(); - ev.preventDefault(); - window.removeEventListener('click', swallow, true); - }; - window.addEventListener('click', swallow, true); - } - } - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - return () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - }; - }, [dragRect]); + const marquee = useMarqueeSelection({ + scrollRef: bodyRef, + itemSelector: '[data-row-index]', + keyAttr: 'rowPath', + getSelection: () => selectedPaths, + setSelection: setSelectedPaths, + onClear: clearSelection, // also resets the shift-select anchor + threshold: DRAG_THRESHOLD, + }); // ── Intra-app drag (move/copy) ─────────────────────────────────── useEffect(() => { @@ -592,8 +528,7 @@ function FilesPage() { }, []); const handleDragStart = useCallback((e: React.DragEvent, entry: BackendDirent) => { - dragOriginRef.current = null; - setDragRect(null); + marquee.cancel(); e.dataTransfer.effectAllowed = 'copyMove'; const dragPaths = selectedPaths.has(entry.path) ? Array.from(selectedPaths) @@ -767,7 +702,7 @@ function FilesPage() { ref={bodyRef} className={`cw-file-body cw-view-${viewMode}${isDraggingOver ? ' is-over' : ''}`} onClick={(e) => { if (e.target === e.currentTarget) clearSelection(); }} - onMouseDown={onBodyMouseDown} + onMouseDown={marquee.onMouseDown} onDragEnter={(e) => { if (e.dataTransfer.types.includes('application/x-cowork-dirent-paths')) return; e.preventDefault(); @@ -850,13 +785,7 @@ function FilesPage() {
- {dragRect && createPortal( -
, - document.body, - )} + {folderDialogOpen && ( ([]); + // Right sidebar swaps between the default info view and a full file browser + // (slides across when the user taps the "browse shared files" button). + const [sideView, setSideView] = useState<'info' | 'files'>('info'); + // Reset when switching sessions — done during render (not in an effect) so React // StrictMode's double-invoked effects can't wipe the optimistic user/ai bubbles // the initial-send effect adds on entry (React's "adjust state on prop change"). @@ -220,6 +226,7 @@ function SessionPage() { wsInactivityTimerRef.current = null; } setPendingAttachments([]); + setSideView('info'); setShowNewMsgPill(false); setShowScrollDown(false); wsOutputsRef.current.clear(); @@ -423,12 +430,96 @@ function SessionPage() { } }, [composerText, composerExpanded]); + // Highlight the chat surface while a shared file is dragged over it. + const [importDragOver, setImportDragOver] = useState(false); + + + // Import shared files (dragged from SharedFilesBrowser, its inline button, or a + // folder drop expanded to its files) as pending attachments. They already exist + // in shared storage, so no upload is needed — we just carry the global path the + // send flow forwards. Dedupe against pending paths; if the batch would push the + // total past MAX_ATTACHMENTS, reject the whole batch (attach nothing) and toast. + const importSharedFiles = useCallback((items: SessionImportItem[]) => { + if (items.length === 0) return; + const curPaths = new Set(pendingAttachments.map((a) => a.globalPath).filter(Boolean)); + const fresh = items.filter((it) => !curPaths.has(it.globalPath)); + if (fresh.length === 0) return; // everything already attached + if (pendingAttachments.length + fresh.length > MAX_ATTACHMENTS) { + showToast(t('shared_files.attach_limit', { max: MAX_ATTACHMENTS })); + return; // over the cap — don't attach any of them + } + // Dedupe against the authoritative `prev` inside the updater, so a repeated + // or concurrent call (e.g. StrictMode's double-invoked effect) can't append + // 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)) + .slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)); + if (toAdd.length === 0) return prev; + return [ + ...prev, + ...toAdd.map((it, i) => ({ + tempId: `shared-${Date.now()}-${i}-${it.filename}`, + filename: it.filename, + status: 'uploaded' as const, + globalPath: it.globalPath, + })), + ]; + }); + }, [pendingAttachments, showToast, t]); + + const handleImportDrop = useCallback((e: React.DragEvent) => { + const raw = e.dataTransfer.getData(SESSION_IMPORT_MIME); + if (!raw) return; + e.preventDefault(); + setImportDragOver(false); + // Clear any selection the drag formed underneath the overlay, so it doesn't + // reappear once the overlay is gone. + window.getSelection()?.removeAllRanges(); + let items: SessionImportItem[]; + try { items = JSON.parse(raw); } catch { return; } + if (Array.isArray(items)) importSharedFiles(items); + }, [importSharedFiles]); + + // Files dragged from the Files page onto this session's row arrive as + // scope-relative shared paths in router state. Attach them once session/project + // resolve, then clear the state so a refresh doesn't re-attach. A dropped folder + // is expanded into the files it contains (recursively). + useEffect(() => { + const rels = location.state.attachShared; + if (!rels?.length || !projectId || !sessionId) return; + void navigate({ replace: true, state: (prev) => ({ ...prev, attachShared: undefined }) }); + void (async () => { + const sharedScope: DirentScope = { kind: 'shared', projectId }; + const root = scopeRoot(sharedScope); + let entries: Awaited> = []; + try { + entries = await queryClient.fetchQuery({ + queryKey: ['dirents', 'shared', projectId], + queryFn: () => listDirentsRaw(sharedScope, true), + }); + } catch { /* fall back to treating each path as a file */ } + // Folders expand to their files (recursive, .keep/dotfiles skipped, deduped). + const items = expandDirentPaths(entries, rels.map((rel) => `${root}/${rel}`)); + if (items.length) importSharedFiles(items); + })(); + }, [location.state.attachShared, projectId, sessionId, importSharedFiles, navigate, queryClient]); + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []); if (files.length === 0) return; // Reset input so same file can be selected again e.target.value = ''; + // Cap like the shared-import path: if this batch would push the total past + // MAX_ATTACHMENTS, reject the whole batch (upload nothing) and toast — so the + // UI can't reach the backend's hard 400 on >MAX attachments. + if (pendingAttachments.length + files.length > MAX_ATTACHMENTS) { + showToast(t('shared_files.attach_limit', { max: MAX_ATTACHMENTS })); + return; + } + for (const file of files) { const tempId = `${Date.now()}-${file.name}`; setPendingAttachments((prev) => [...prev, { tempId, filename: file.name, status: 'uploading' }]); @@ -452,7 +543,7 @@ function SessionPage() { )); } } - }, [projectId, sessionId]); + }, [projectId, sessionId, pendingAttachments, showToast, t]); // Clears all run/streaming state for a run that ended without a terminal WS event. const resetEndedRun = useCallback(() => { @@ -912,7 +1003,21 @@ function SessionPage() { return (
-
+
{ + if (!e.dataTransfer.types.includes(SESSION_IMPORT_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + if (!importDragOver) setImportDragOver(true); + }} + onDragLeave={(e) => { + // Ignore leaves into descendant elements — only clear when leaving the surface. + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setImportDragOver(false); + }} + onDrop={handleImportDrop} + >
@@ -1028,6 +1133,7 @@ function SessionPage() { key={a.tempId} filename={a.filename} status={a.status} + shared={a.globalPath ? parseGlobalPath(a.globalPath)?.scope.kind === 'shared' : false} error={a.error} onRemove={() => setPendingAttachments((prev) => prev.filter((x) => x.tempId !== a.tempId))} /> @@ -1084,30 +1190,69 @@ function SessionPage() {
{copyToShared !== null && ( diff --git a/app/src/styles/cowork-design-system.css b/app/src/styles/cowork-design-system.css index 8b62a676..612d3570 100644 --- a/app/src/styles/cowork-design-system.css +++ b/app/src/styles/cowork-design-system.css @@ -39,8 +39,8 @@ Subtle wash on top of paper — the "selected" signal lives in the border / left-stripe / ring rather than in a saturated fill, matching Drive / OneDrive behavior. Hue stays in the accent family. */ - --cw-selected-bg: oklch(0.955 0.018 215); /* +3% lightness gap, low chroma */ - --cw-selected-bg-2: oklch(0.945 0.022 215); /* selected + hover, barely deeper */ + --cw-selected-bg: color-mix(in oklch, var(--cw-accent) 14%, transparent); /* accent wash — shared by Files rows/cards + the shared-file panel */ + --cw-selected-bg-2: color-mix(in oklch, var(--cw-accent) 22%, transparent); /* selected + hover, deeper */ --cw-selected-border: var(--cw-accent); --cw-selected-ring: color-mix(in oklch, var(--cw-accent), transparent 86%); diff --git a/app/src/styles/globals.css b/app/src/styles/globals.css index 757d1e41..26b32003 100644 --- a/app/src/styles/globals.css +++ b/app/src/styles/globals.css @@ -246,7 +246,7 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-brand-lockup strong { color: var(--cw-fg-1); font-size: 22px; line-height: 1; letter-spacing: -0.035em; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cw-section-label-app { padding: 5px 3px 5px; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--cw-fg-3); font-weight: 500; } .cw-nav-list, .cw-session-rail { display: flex; flex-direction: column; gap: 1px; } -.cw-nav-row, .cw-session-row { width: 100%; min-height: 31px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--cw-radius-md); background: transparent; color: var(--cw-fg-2); padding: 6px 8px; text-align: left; font-size: 13px; transition: background 120ms, color 120ms, transform 120ms; } +.cw-nav-row, .cw-session-row { width: 100%; min-height: 31px; display: flex; align-items: center; gap: 9px; border: 0; border-radius: var(--cw-radius-md); background: transparent; color: var(--cw-fg-2); padding: 6px 8px; text-align: left; font-size: 13px; user-select: none; transition: background 120ms, color 120ms, transform 120ms; } .cw-nav-row:hover, .cw-session-row:hover { background: var(--cw-bg-muted); color: var(--cw-fg-1); } .cw-nav-row:active, .cw-session-row:active, .cw-btn-primary:active, .cw-btn-secondary:active { transform: translateY(1px); } .cw-nav-row.is-active, .cw-session-row.is-active { background: var(--cw-bg-muted); color: var(--cw-fg-1); } @@ -260,6 +260,8 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-session-row { min-height: 28px; padding: 5px 8px; font-size: 12.5px; color: var(--cw-fg-3); } .cw-session-row.is-active { background: color-mix(in oklch, var(--cw-accent) 12%, var(--cw-bg-subtle)); color: var(--cw-fg-1); font-weight: 600; } .cw-session-row.is-unread { color: var(--cw-fg-1); font-weight: 600; } +/* Dragging a file from the Files page over a session row — drop to attach it. */ +.cw-session-row.is-drop-target { background: color-mix(in oklch, var(--cw-accent) 18%, var(--cw-bg-subtle)); box-shadow: inset 0 0 0 1.5px var(--cw-accent); color: var(--cw-fg-1); } .auto-dot { margin-left: auto; color: var(--cw-accent); font-size: 10px; } .cw-sidebar-user { border-top: 1px solid var(--cw-border); padding: 10px 6px 2px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center; color: var(--cw-fg-2); } .cw-sidebar-user-meta { min-width: 0; } @@ -316,7 +318,17 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-activity-row time { color: var(--cw-fg-3); font-family: var(--cw-font-mono); font-size: 11px; } .cw-session-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; flex: 1; min-height: 0; overflow: hidden; } -.cw-chat-surface { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--cw-border); background: var(--cw-bg); overflow: hidden; } +.cw-chat-surface { position: relative; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--cw-border); background: var(--cw-bg); overflow: hidden; } +/* While a shared file is dragged over the conversation, lay a single uniform + accent overlay over the whole surface (a clean drop zone) instead of letting + the drag paint a ragged text selection. pointer-events:none so the drop still + reaches the surface underneath. */ +.cw-chat-surface.is-import-target::after { + content: ''; position: absolute; inset: 0; z-index: 5; pointer-events: none; + background: color-mix(in oklch, var(--cw-accent), transparent 82%); + /* Ring on the overlay (above content) so opaque children can't break it. */ + box-shadow: inset 0 0 0 2px var(--cw-accent); +} .cw-chat-head { padding: 34px 28px 18px; border-bottom: 1px solid var(--cw-border); display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } .cw-chat-head p { display: flex; align-items: center; gap: 7px; color: var(--cw-fg-3); margin: 14px 0 0; } /* Session title shares its row with the agent chip (mode lives at title level). */ @@ -337,9 +349,70 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-message-meta time { color: var(--cw-fg-4); font-family: var(--cw-font-mono); } .cw-message p { margin: 7px 0 0; line-height: 1.65; white-space: pre-wrap; } .cw-composer small { grid-column: 1 / -1; color: var(--cw-fg-4); font-size: 11px; } -.cw-session-side { padding: 22px 18px; background: var(--cw-bg-subtle); overflow: auto; } +.cw-session-side { padding: 0; background: var(--cw-bg-subtle); overflow: hidden; display: flex; flex-direction: column; } .cw-session-side h3 { margin: 16px 0 10px; font-size: 11px; text-transform: uppercase; letter-spacing: .09em; color: var(--cw-fg-3); font-weight: 500; } .cw-session-side p { color: var(--cw-fg-3); line-height: 1.55; } + +/* Two sidebar views (info | file browser) on a horizontal track, ordered to + match the bottom tabs: info on the left, files on the right. Switching to + Files slides the track left so the file browser glides in from the right. */ +.cw-side-views { flex: 1; min-height: 0; display: flex; width: 200%; transform: translateX(0); transition: transform .28s cubic-bezier(.4, 0, .2, 1); } +.cw-side-views.show-files { transform: translateX(-50%); } +.cw-side-view { width: 50%; flex: 0 0 50%; min-height: 0; display: flex; flex-direction: column; } +.cw-side-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 18px; } + +/* Bottom view switch — pinned outside the sliding track, same position in both + views. Segmented control: two tabs (session info ↔ shared file browser). */ +.cw-side-switchbar { flex-shrink: 0; padding: 10px 16px; border-top: 1px solid var(--cw-border); } +.cw-side-seg { display: flex; gap: 2px; width: 100%; padding: 3px; background: var(--cw-bg-muted); border-radius: var(--cw-radius-md); } +.cw-side-seg-tab { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 30px; border: 0; border-radius: calc(var(--cw-radius-md) - 3px); background: transparent; color: var(--cw-ink-3); font-size: 12.5px; font-weight: 500; cursor: pointer; transition: background 120ms, color 120ms, box-shadow 120ms; } +.cw-side-seg-tab:hover { color: var(--cw-ink); } +.cw-side-seg-tab[aria-selected="true"] { background: var(--cw-accent-soft); color: var(--cw-accent-2); box-shadow: inset 0 0 0 1px var(--cw-accent-line); font-weight: 600; } + +/* File-browser view inside the sidebar — mirrors the Files page layout in the + narrow column: fixed head + breadcrumb, scrolling row list. */ +.cw-files-browser { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.cw-files-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 1px; flex-shrink: 0; padding: 20px 14px 8px; font-size: 11px; color: var(--cw-ink-3); } +.cw-files-up { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-right: 3px; border: 0; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-ink-2); cursor: pointer; transition: background 100ms, color 100ms; } +.cw-files-up:not(:disabled):hover { background: var(--cw-bg-muted); color: var(--cw-ink); } +/* Reserve the slot at root so entering a folder doesn't shift the breadcrumb. */ +.cw-files-up:disabled { visibility: hidden; } +/* Keep the project-name root constant whether or not it's the current dir + (the home button is disabled at root, enabled deeper — don't let that recolor it). */ +.cw-files-home, .cw-files-home:disabled { gap: 5px; max-width: 150px; color: var(--cw-ink-2); } +.cw-files-home-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.cw-files-breadcrumb button { display: inline-flex; align-items: center; border: 0; background: transparent; color: var(--cw-ink-3); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cw-files-breadcrumb button:not(:disabled):hover { background: var(--cw-bg-muted); color: var(--cw-ink); } +/* pointer-events:none so releasing a marquee over a disabled (gray) crumb still + fires window mouseup — disabled buttons otherwise swallow the event. */ +.cw-files-breadcrumb button:disabled { cursor: default; color: var(--cw-ink-2); pointer-events: none; } +.cw-files-crumb { display: inline-flex; align-items: center; } +.cw-files-browser-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 14px; display: flex; flex-direction: column; gap: 1px; user-select: none; } + +/* Shared-file rows: color-chip icon (FileTypeIcon) + name, with a drag handle + that fades in on hover to signal the row is draggable. */ +.cw-sf-row { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 34px; padding: 3px 6px 3px 3px; border: 0; border-radius: var(--cw-radius-sm); background: transparent; font-size: 13px; color: var(--cw-fg-1); text-align: left; transition: background 100ms; } +.cw-sf-row:hover { background: var(--cw-bg-muted); } +.cw-sf-row.is-selected { background: var(--cw-selected-bg); } +.cw-sf-row.is-selected:hover { background: var(--cw-selected-bg-2); } +.cw-sf-row .cw-file-label { flex: 1; } +.cw-sf-row[draggable="true"] { cursor: grab; } +/* Left type-icon swaps to a quick-add (+) on hover. */ +.cw-sf-icon { position: relative; width: 18px; height: 18px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; } +.cw-sf-icon > :not(.cw-sf-icon-add) { transition: opacity 100ms; } +.cw-sf-row:hover .cw-sf-icon > :not(.cw-sf-icon-add) { opacity: 0; } +/* pointer-events:none while hidden — an opacity:0 button still takes clicks, so + without this a tap on the icon (touch, where there's no hover to reveal the +) + would import the file instead of selecting the row. */ +.cw-sf-icon-add { position: absolute; inset: 0; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 1px solid transparent; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-accent); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 100ms, background 100ms, border-color 100ms; } +/* Visible (and clickable) on row hover (or focus): a clear neutral border so the + + reads as a button. (--cw-border was too faint and washed out on the + accent-wash selected fill.) */ +.cw-sf-row:hover .cw-sf-icon-add, .cw-sf-icon-add:focus-visible { opacity: 1; pointer-events: auto; border-color: var(--cw-ink-4); } +/* Hovering the + itself deepens its fill and its border (neutral, a couple steps darker). */ +.cw-sf-row:hover .cw-sf-icon-add:hover, .cw-sf-icon-add:focus-visible:hover { background: color-mix(in oklch, var(--cw-ink) 12%, transparent); border-color: var(--cw-ink-2); } +/* Drag affordance hint under the breadcrumb (replaces the old per-row grip). */ +.cw-sf-drag-hint { margin: 0; padding: 0 14px 6px; font-size: 10px; color: var(--cw-ink-4); flex-shrink: 0; user-select: none; } .cw-side-row, .cw-side-file { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; font-size: 13px; } .cw-side-file > span { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cw-side-file small { color: var(--cw-fg-4); font-family: var(--cw-font-mono); } @@ -358,6 +431,10 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-artifact-row { display: flex; align-items: center; gap: 6px; min-height: 30px; padding: 2px 4px; border-radius: var(--cw-radius-sm); font-size: 12.5px; transition: background 100ms; } .cw-artifact-row:hover { background: var(--cw-bg-muted); } .cw-artifact-name { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cw-fg-1); } +/* The icon stays fixed; the label takes the remaining width and ellipsizes. + (text-overflow needs a real element — a bare text node in the flex row won't clip.) */ +.cw-artifact-name > svg, .cw-artifact-name > img { flex-shrink: 0; } +.cw-file-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cw-artifact-size { flex-shrink: 0; font-family: var(--cw-font-mono); font-size: 10.5px; color: var(--cw-fg-4); } .cw-artifact-menu-wrap { flex-shrink: 0; position: relative; } .cw-artifact-menu-wrap > button { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: var(--cw-radius-sm); background: transparent; color: var(--cw-fg-3); cursor: pointer; opacity: 0; transition: opacity 100ms, background 100ms; } @@ -535,7 +612,7 @@ body.is-resizing-sidebar .cw-app-shell[data-sidebar-mode="hidden"] .cw-sidebar-a .cw-file-head, .cw-file-row { display: grid; grid-template-columns: minmax(0, 1fr) 110px 130px 24px; gap: 12px; align-items: center; } .cw-file-head { padding: 10px 8px; border-bottom: 1px solid var(--cw-border); color: var(--cw-fg-3); font-family: var(--cw-font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } .cw-file-row { width: 100%; border: 0; border-bottom: 1px solid var(--cw-line-soft); background: transparent; min-height: 46px; padding: 7px 8px; color: var(--cw-fg-2); text-align: left; } -.cw-file-row:hover, .cw-file-row.is-selected { background: var(--cw-bg); } +.cw-file-row:hover { background: var(--cw-bg); } .cw-file-row > span:first-child:not(.cw-pocket) { display: inline-flex; align-items: center; gap: 9px; color: var(--cw-fg-1); } .cw-knowledge { margin-top: 28px; border: 1px dashed var(--cw-border-hover); border-radius: var(--cw-radius-lg); padding: 18px; background: var(--cw-bg); } .cw-knowledge h2 { margin: 0; display: flex; align-items: center; gap: 8px; } @@ -1034,8 +1111,9 @@ button.cw-select[aria-expanded="true"] .cw-select-caret { border-bottom: 1px solid var(--cw-line-soft); color: var(--cw-ink); } -.cw-file-row:hover, -.cw-file-row.is-selected { background: var(--cw-paper-2); } +.cw-file-row:hover { background: var(--cw-paper-2); } +.cw-file-row.is-selected { background: var(--cw-selected-bg); } +.cw-file-row.is-selected:hover { background: var(--cw-selected-bg-2); } .cw-file-main { flex: 1; min-width: 0; @@ -1613,6 +1691,11 @@ button.cw-select[aria-expanded="true"] .cw-select-caret { transition: background 120ms, border-color 120ms; } .cw-attach-chip--file:hover { background: var(--cw-paper-4); border-color: var(--cw-border-hover); } +/* Source marker on an attachment chip: folder = referenced from shared files + (accent), clip = uploaded via the composer (muted). */ +.cw-attach-source { display: inline-flex; align-items: center; flex-shrink: 0; } +.cw-attach-source--shared { color: var(--cw-accent); } +.cw-attach-source--upload { color: var(--cw-ink-2); } .cw-attach-name { flex: 1; min-width: 0; @@ -1620,18 +1703,13 @@ button.cw-select[aria-expanded="true"] .cw-select-caret { text-overflow: ellipsis; white-space: nowrap; } -.cw-attach-remove { - display: inline-flex; - align-items: center; - justify-content: center; - border: 0; - background: transparent; - color: var(--cw-ink-4); - cursor: pointer; - padding: 0; - flex-shrink: 0; -} -.cw-attach-remove:hover { color: var(--cw-ink); } +/* Pending composer chip: the whole chip is click-to-remove; hovering turns it red. */ +.cw-attach-chip--removable { cursor: pointer; transition: background 120ms, border-color 120ms, color 120ms; } +.cw-attach-chip--removable:hover { background: color-mix(in oklch, var(--cw-destructive), white 90%); border-color: color-mix(in oklch, var(--cw-destructive), transparent 55%); color: var(--cw-destructive); } +.cw-attach-chip--removable:focus-visible { outline: 2px solid color-mix(in oklch, var(--cw-destructive), transparent 40%); outline-offset: 1px; } +/* Persistent × affordance; muted by default, reddens with the chip on hover. */ +.cw-attach-remove-hint { display: inline-flex; align-items: center; flex-shrink: 0; color: var(--cw-ink-4); } +.cw-attach-chip--removable:hover .cw-attach-remove-hint { color: var(--cw-destructive); } .cw-attach-error { color: var(--cw-destructive); display: inline-flex; } .cw-attach-spinner { font-size: 10px; } diff --git a/backend/src/handlers/session.rs b/backend/src/handlers/session.rs index 2202369a..6edd2c27 100644 --- a/backend/src/handlers/session.rs +++ b/backend/src/handlers/session.rs @@ -356,6 +356,21 @@ async fn resolve_session_id(state: &Arc, session_ref: &str) -> ApiResu } } +/// Hard ceiling on attachments per message — a server-side backstop for the +/// frontend's softer limit. Bounds the agent prompt, DB row, and fs stats even +/// if a crafted request bypasses the client. +const MAX_ATTACHMENTS: usize = 30; + +/// Reject a message that attaches more than [`MAX_ATTACHMENTS`] files. +pub fn ensure_attachment_count(count: usize) -> ApiResult<()> { + if count > MAX_ATTACHMENTS { + return Err(AppError::bad_request(format!( + "too many attachments: {count} (max {MAX_ATTACHMENTS})" + ))); + } + Ok(()) +} + async fn validate_attachments( state: &Arc, auth_user: &AuthUser, @@ -363,6 +378,7 @@ async fn validate_attachments( project_id: Uuid, attachments: &[String], ) -> ApiResult<()> { + ensure_attachment_count(attachments.len())?; for path in attachments { let parsed = parse_dirent_path(path) .map_err(|_| AppError::bad_request(format!("invalid attachment path: {path}")))?; diff --git a/backend/tests/session_messages_test.rs b/backend/tests/session_messages_test.rs index 86e775e0..a94982b8 100644 --- a/backend/tests/session_messages_test.rs +++ b/backend/tests/session_messages_test.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use agent_k::agents::{GUEST_ATTACHED_DIR, GUEST_SHARED_DIR}; use agent_k_backend::{ - handlers::{build_attachment_note, inject_attachment_note}, + handlers::{build_attachment_note, ensure_attachment_count, inject_attachment_note}, repository, state::AppState, }; @@ -811,6 +811,7 @@ fn inject_then_re_inject_is_idempotent_on_text_prefix() { ); } + // ── reverse-indexed pagination ──────────────────────────────────────────────── fn history_texts(body: &serde_json::Value) -> Vec { @@ -845,6 +846,17 @@ fn history_seqs(body: &serde_json::Value) -> std::collections::HashMap