diff --git a/backend/__tests__/unit/routes/uploads.post.test.js b/backend/__tests__/unit/routes/uploads.post.test.js index 1b98bf95..d451034d 100644 --- a/backend/__tests__/unit/routes/uploads.post.test.js +++ b/backend/__tests__/unit/routes/uploads.post.test.js @@ -18,7 +18,13 @@ jest.mock('../../../services/objectStore', () => ({ jest.mock('../../../models/File', () => { const saveMock = jest.fn().mockResolvedValue(undefined); - const File = jest.fn().mockImplementation((data) => ({ ...data, save: saveMock })); + // Mirror the post-save mongoose shape: `_id` populates synchronously + // when constructing a new model. Tests use the static fixture below. + const File = jest.fn().mockImplementation((data) => ({ + ...data, + _id: { toString: () => 'fake-file-id-67abcdef0123456789abcdef' }, + save: saveMock, + })); File.findByFileName = jest.fn(); File.__saveMock = saveMock; return File; @@ -45,10 +51,13 @@ describe('uploads POST / (ADR-002 Phase 1)', () => { }); it('writes bytes through the driver and saves metadata-only File', async () => { - await request(app) + const res = await request(app) .post('/api/uploads') .attach('image', Buffer.from('data'), 'photo.png') .expect(200); + // _id surfaced in response so the composer can bake it into the + // [[upload:…|fileId]] directive for click-to-open-inspector. + expect(res.body._id).toBe('fake-file-id-67abcdef0123456789abcdef'); // Driver received the bytes + mime expect(mockStore.put).toHaveBeenCalledWith( diff --git a/backend/routes/uploads.ts b/backend/routes/uploads.ts index c1bceb3b..46b0ee66 100644 --- a/backend/routes/uploads.ts +++ b/backend/routes/uploads.ts @@ -202,6 +202,12 @@ const handleUpload = async ( res.json({ url, + // _id surfaced so the v2 composer can include it in the + // [[upload:fn|on|sz|kind|fileId]] directive — clicking the file pill + // then opens the inspector to the artifact detail (instead of just + // `window.open`-ing the bytes), which is where preview + download + // live now. + _id: newFile._id?.toString(), fileName, originalName: req.file.originalname, contentType: req.file.mimetype, diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 8922cf0f..f7a4e9c9 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -101,6 +101,7 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { inspectorCollapsed={inspectorCollapsed} onToggleInspector={selectedPodId ? toggleInspector : undefined} onOpenMember={openInspectorMember} + onOpenArtifact={openInspectorArtifact} /> {selectedPodId && !inspectorCollapsed && ( void; + // Clicking a file pill opens the inspector to the artifact detail (where + // preview + download live). When omitted, the pill falls back to opening + // the bytes in a new tab via signed URL — works for users who haven't yet + // upgraded to the inspector-aware flow but loses the preview-first UX. + onArtifactClick?: (artifactId: string) => void; } interface ParsedFile { name: string; ext: string; size?: string; + kind?: string; // Set when the pill came from an [[upload:...]] directive backed by a real - // ObjectStore record. Click → mint signed URL → open. Plain [[file:...]] - // pills (used by demo fixtures) leave this undefined and render as static. + // ObjectStore record. Click → open inspector artifact detail (or fall back + // to a signed-URL new-tab if no inspector handle is wired). Plain + // [[file:...]] pills (used by demo fixtures) leave both `fileName` and + // `fileId` undefined and render as static. fileName?: string; + fileId?: string; } interface ParsedReaction { @@ -122,11 +131,13 @@ const FILE_EXT_COLORS: Record = { // Message model gains a real `attachments[]` field. const FILE_TOKEN_RE = /\[\[file:([^\]|]+)(?:\|([^\]]+))?\]\]/g; // Match a real upload directive emitted by the composer / agent SDK after a -// successful POST /api/uploads: -// [[upload:|||]] -// fileName is the ObjectStore key (e.g. `1714678910-712345678.pdf`); the pill -// click handler exchanges it for a short-TTL signed URL via getSignedAttachmentUrl. -const UPLOAD_TOKEN_RE = /\[\[upload:([^\]|]+)\|([^\]|]+)\|([^\]|]+)(?:\|([^\]]+))?\]\]/g; +// successful POST /api/uploads. Permissive bracket capture; the inner is +// split on `|` to support both the original 4-field shape and the new +// 5-field shape that adds the File _id (so click can open the inspector). +// [[upload:|||]] (legacy) +// [[upload:||||]] (current) +// fileName is the ObjectStore key (e.g. `1714678910-712345678.pdf`). +const UPLOAD_TOKEN_RE = /\[\[upload:([^\]]+)\]\]/g; const formatBytes = (raw: string | number): string => { const n = typeof raw === 'number' ? raw : parseInt(raw, 10); if (!Number.isFinite(n) || n <= 0) return ''; @@ -145,14 +156,28 @@ const IMAGE_URL_RE = /^https?:\/\/.+\.(png|jpe?g|gif|webp)(\?.*)?$/i; const parseFiles = (content: string): { stripped: string; files: ParsedFile[] } => { const files: ParsedFile[] = []; - // Real uploads first — they carry a fileName and resolve to a signed URL on - // click. Then static file tokens (demo fixtures, no backend reference). - let working = content.replace(UPLOAD_TOKEN_RE, (_match, rawFileName, rawOriginal, rawSize) => { - const fileName = String(rawFileName).trim(); - const name = String(rawOriginal).trim(); + // Real uploads first — they carry a fileName and (since 2026-05-03) a + // fileId that lets click route through the inspector. Permissive split: + // any extra fields beyond `fileId` are ignored, so future schema growth + // doesn't break old-message render. + let working = content.replace(UPLOAD_TOKEN_RE, (_match, rawInner) => { + const parts = String(rawInner).split('|').map((p) => p.trim()); + const fileName = parts[0] || ''; + const name = parts[1] || fileName; + const size = parts[2] || ''; + const kind = parts[3] || 'file'; + const fileId = parts[4] || undefined; + if (!fileName) return ''; const dot = name.lastIndexOf('.'); - const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : 'file'; - files.push({ fileName, name, ext, size: formatBytes(String(rawSize).trim()) || undefined }); + const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : (kind || 'file'); + files.push({ + fileName, + fileId, + name, + ext, + kind, + size: formatBytes(size) || undefined, + }); return ''; }); working = working.replace(FILE_TOKEN_RE, (_match, rawName, rawSize) => { @@ -165,6 +190,37 @@ const parseFiles = (content: string): { stripped: string; files: ParsedFile[] } return { stripped: working.trim(), files }; }; +// Human-readable file kind for the pill subline. Examples: PDF, Word doc, +// Excel sheet, JSON, Image. Falls through to the bare extension upper-cased +// when nothing matches — beats showing the raw `office` / `archive` / `data` +// classifier from the backend. +const KIND_LABELS: Record = { + pdf: 'PDF', + doc: 'Word document', + docx: 'Word document', + xls: 'Excel spreadsheet', + xlsx: 'Excel spreadsheet', + ppt: 'PowerPoint', + pptx: 'PowerPoint', + odt: 'OpenDocument', + ods: 'OpenDocument sheet', + odp: 'OpenDocument slides', + md: 'Markdown', + txt: 'Text', + csv: 'CSV', + json: 'JSON', + zip: 'Zip archive', + png: 'PNG image', + jpg: 'JPEG image', + jpeg: 'JPEG image', + gif: 'GIF', + webp: 'WebP image', + svg: 'SVG image', +}; +const friendlyKindFor = (file: ParsedFile): string => ( + KIND_LABELS[file.ext] || file.ext.toUpperCase() || 'File' +); + const parseReactions = (content: string): { stripped: string; reactions: ParsedReaction[] } => { const reactions: ParsedReaction[] = []; const stripped = content.replace(REACTION_TOKEN_RE, (_match, body) => { @@ -185,8 +241,14 @@ const parseReactions = (content: string): { stripped: string; reactions: ParsedR return { stripped, reactions }; }; -const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { +const FilePill: React.FC<{ + file: ParsedFile; + onArtifactClick?: (artifactId: string) => void; +}> = ({ file, onArtifactClick }) => { const color = FILE_EXT_COLORS[file.ext] || '#94a3b8'; + // Two-line layout matches Slack / Google Chat / Linear: filename owns the + // visual weight, the kind + size sit below as muted metadata. + const subline = [friendlyKindFor(file), file.size].filter(Boolean).join(' · '); const inner = ( <> @@ -194,7 +256,7 @@ const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { {file.name} - {file.size && {file.size}} + {subline && {subline}} ); @@ -202,11 +264,17 @@ const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { if (!file.fileName) { return {inner}; } - // Real upload — mint signed URL on click. Don't fetch eagerly: a chat with - // 50 file messages would mint 50 tokens on render. Mint on demand keeps the - // 30/min/user rate limit comfortable. + // Real upload — preferred path is to open the inspector to the artifact + // detail (where preview + download live). When the host hasn't wired + // `onArtifactClick` (or this is an old-format directive without a fileId), + // fall back to minting a signed URL and opening in a new tab. Don't fetch + // eagerly on render — a chat with 50 file messages would mint 50 tokens. const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); + if (onArtifactClick && file.fileId) { + onArtifactClick(`file-${file.fileId}`); + return; + } const signed = await getSignedAttachmentUrl(`/api/uploads/${file.fileName}`); if (signed) { window.open(signed, '_blank', 'noopener,noreferrer'); @@ -217,14 +285,14 @@ const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { type="button" className="v2-msg__file v2-msg__file--clickable" onClick={handleClick} - aria-label={`Open ${file.name}`} + aria-label={`Preview ${file.name}`} > {inner} ); }; -const V2MessageBubble: React.FC = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick }) => { +const V2MessageBubble: React.FC = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick, onArtifactClick }) => { const { currentUser } = useAuth(); const rawUsername = message.user?.username || 'Unknown'; const overriddenDisplay = agentDisplayNames?.get(rawUsername); @@ -299,7 +367,7 @@ const V2MessageBubble: React.FC = ({ message, isLead, agen ) )} {files.map((file, idx) => ( - + ))} {reactions.length > 0 && (
diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index dabf5973..87150f0f 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -122,6 +122,10 @@ interface V2PodChatProps { inspectorCollapsed?: boolean; onToggleInspector?: () => void; onOpenMember?: (agentKey: string) => void; + // Clicking a file pill in a message opens the inspector to the artifact + // detail (preview + download). When omitted the pill falls back to a + // signed-URL new-tab so behavior degrades gracefully. + onOpenArtifact?: (artifactId: string) => void; } const Icon = ({ d }: { d: string }) => ( @@ -130,7 +134,7 @@ const Icon = ({ d }: { d: string }) => ( ); -const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember }) => { +const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenArtifact }) => { const { pod, members, messages, agents, sendMessage, loading, error } = detail; const navigate = useNavigate(); const api = useV2Api(); @@ -470,6 +474,7 @@ const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onTog formData.append('image', file); // legacy multer field name formData.append('podId', pod._id); const uploaded = await api.post<{ + _id?: string; url?: string; fileName?: string; originalName?: string; @@ -483,7 +488,18 @@ const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onTog return; } if (uploaded.fileName) { - const directive = `[[upload:${uploaded.fileName}|${uploaded.originalName || file.name}|${uploaded.size || file.size}|${uploaded.kind || 'file'}]]`; + // Directive shape: fileName | originalName | size | kind | fileId + // The 5th field (fileId) lets the bubble click handler open the + // inspector to the artifact detail. Older messages have only 4 + // fields; the parser accepts both shapes. + const parts = [ + uploaded.fileName, + uploaded.originalName || file.name, + String(uploaded.size || file.size), + uploaded.kind || 'file', + ]; + if (uploaded._id) parts.push(uploaded._id); + const directive = `[[upload:${parts.join('|')}]]`; setDraft((prev) => (prev ? `${prev.replace(/\s+$/, '')} ${directive}` : directive)); } } catch (err) { @@ -611,6 +627,7 @@ const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onTog agentDisplayNames={agentDisplayNames} agentAuthorKeys={agentAuthorKeys} onAuthorClick={onOpenMember ? handleAuthorClick : undefined} + onArtifactClick={onOpenArtifact} /> ))}
diff --git a/frontend/src/v2/components/V2PodInspector.tsx b/frontend/src/v2/components/V2PodInspector.tsx index ffc15215..e55ec362 100644 --- a/frontend/src/v2/components/V2PodInspector.tsx +++ b/frontend/src/v2/components/V2PodInspector.tsx @@ -994,15 +994,29 @@ const V2PodInspector: React.FC = ({ // File artifacts open via a signed-URL mint (no plain href to embed). URL // artifacts use safeHref. Click handler always preventDefault so we can // route both kinds through one button. - const handleOpenArtifact = async (item: typeof artifactItems[0]) => { - if (item.fileName) { - const signed = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`); - if (signed) window.open(signed, '_blank', 'noopener,noreferrer'); - return; - } + // URL artifacts: open the external page in a new tab. Files use the + // signed-URL flow + `` so the browser saves the bytes + // instead of trying to render them again (the user already has the + // inline preview above the actions). + const handleOpenUrl = (item: typeof artifactItems[0]) => { const href = safeHref(item.url); if (href) window.open(href, '_blank', 'noopener,noreferrer'); }; + const handleDownloadFile = async (item: typeof artifactItems[0]) => { + if (!item.fileName) return; + const signed = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`); + if (!signed) return; + // Programmatic click — works cross-origin because the + // signed URL same-origin proxies through cloudflared. The downloaded + // filename uses the original upload name (item.title) when known. + const a = document.createElement('a'); + a.href = signed; + a.download = item.title || ''; + a.rel = 'noopener noreferrer'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; const renderArtifactDetail = (artifactId: string) => { const found = artifactItems.find((a) => a.id === artifactId); @@ -1030,13 +1044,23 @@ const V2PodInspector: React.FC = ({ /> {openable && (
- + {found.fileName ? ( + + ) : ( + + )}
)} {found.subtitle && ( diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 581aa97a..177c181f 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1672,46 +1672,82 @@ } .v2-msg__file { - width: 300px; - height: 40px; + /* Two-line pill: filename (bold) above kind + size (muted). Matches + Slack / Google Chat / Linear file affordances. The icon tile widens + to 40px so two-character glyphs (PR, PDF, DOC) sit comfortably. */ + width: 320px; display: inline-grid; - grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-columns: 40px minmax(0, 1fr); align-items: center; - gap: 9px; - padding: 0 12px; + gap: 12px; + padding: 10px 12px; background: var(--v2-surface); border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); margin-top: 7px; max-width: 100%; + text-align: left; + transition: border-color 80ms ease, background 80ms ease; +} + +/* When the pill renders on a