From 3ce723cc18d037feb79ba3ed79e1fc692a18ce1a Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Sun, 3 May 2026 12:28:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(v2):=20file=20pill=20UX=20=E2=80=94=20Slac?= =?UTF-8?q?k-style=20hierarchy=20+=20click-to-preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the in-message file pill so the visual hierarchy matches Slack / Google Chat / Linear: filename owns the weight, kind + size sit below as muted metadata. Clicking a file pill now opens the inspector to the artifact detail (where the inline preview lives) instead of opening bytes in a new tab. The artifact detail's primary action splits: - Files → "Download" (signed URL + programmatic ) - URLs → "Open in new tab" ## Visual Before: filename + size on one line at equal weight, 28px icon, 40px fixed height. After: 40px icon, two-line meta — `filename.pdf` (bold) above `PDF · 24 KB` (muted, smaller). ## Click Backend: upload response now surfaces `_id`. Composer bakes it into the directive as a 5th field: [[upload:fileName|originalName|size|kind|fileId]] 4-field directives stay readable (split-on-pipe, missing fields are undefined). Click handler prefers `onArtifactClick(file-${fileId})` when both the prop and the fileId are present; falls back to a signed-URL new-tab otherwise. V2Layout passes `openInspectorArtifact` through V2PodChat to V2MessageBubble, opening the inspector pane (and uncollapsing it) when a pill is clicked. ## Bonus fix Bumped `.v2-msg__file` selector specificity to `.v2-root button.X` so the global v2 button reset doesn't strip the pill's border and background when it renders as a clickable ); }; -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