From e3a123c70555fa5d30131d4dc7b1bda0e1f4a915 Mon Sep 17 00:00:00 2001 From: Lily Shen Date: Mon, 4 May 2026 10:54:59 -0700 Subject: [PATCH] feat(v2-chat): clicking a file pill in chat opens it in the inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, clicking a file pill in a chat message minted a signed URL and `window.open()`'d it in a new tab — so a `.md` file dumped raw markdown source into the browser, a `.csv` triggered a download, etc. The inspector already had a richer ArtifactPreview that renders markdown via ReactMarkdown, pretty-prints JSON, slices CSVs into tables, and wraps PDFs in a viewer — but it was unreachable from chat. Users had to manually navigate to the inspector's Artifacts section and re-find the file by name. This commit threads a new `onOpenFile(fileName)` callback all the way down (V2Layout → V2PodChat → V2MessageBubble → FilePill). When provided: - Clicking a clickable file pill (real upload) routes the inspector to that artifact's preview sub-page instead of opening a new tab - Clicking a static `[[file:foo.md]]` demo token (no backend ref) now also tries to resolve via the inspector's podFiles by originalName, rendering as a clickable pill if a match exists V2Layout introduces `pendingOpenFileName: string | null` state. The chat sets it; the inspector watches for changes, resolves against its loaded `podFiles[]` (matching either ObjectStore key or originalName), calls `onOpenArtifact(\`file-\${_id}\`)`, and clears the pending value via callback. Declarative, survives re-mounts, and degrades gracefully — if the file isn't in the pod's files index, the pending value clears with no navigation, no spinner. Backwards compat: surfaces that don't pass `onOpenFile` (older chat hosts, embedded surfaces) get the original `window.open()` flow. No behavior change for them. Why this exists --------------- File previews in chat are the difference between "we attached a spec.md to the conversation" and "click the spec to read it inline." For a multi-agent workspace whose pitch is "agents produce real artifacts," the second feels like a real product; the first feels like a chat with attached blobs. Net diff: - V2Layout: 14 lines (pending state + handler) - V2PodChat: 6 lines (prop pass-through) - V2MessageBubble: 32 lines (prop typed, threaded to FilePill, static-pill case made clickable when handler present) - V2PodInspector: 25 lines (props, resolver useEffect) Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/v2/components/V2Layout.tsx | 16 +++++++ .../src/v2/components/V2MessageBubble.tsx | 48 ++++++++++++++++--- frontend/src/v2/components/V2PodChat.tsx | 7 ++- frontend/src/v2/components/V2PodInspector.tsx | 27 +++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/frontend/src/v2/components/V2Layout.tsx b/frontend/src/v2/components/V2Layout.tsx index 6c37246f..640b44ca 100644 --- a/frontend/src/v2/components/V2Layout.tsx +++ b/frontend/src/v2/components/V2Layout.tsx @@ -75,6 +75,19 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { setInspectorCollapsed(false); writeInspectorCollapsed(false); }, []); + // Open inspector by ObjectStore filename — used when a chat file pill is + // clicked. The inspector resolves the filename → artifactId via its own + // `podFiles` state and routes to the artifact sub-page. Goes through a + // pending-state pattern (vs. an imperative ref) so the resolution stays + // declarative and survives re-mounts. + const [pendingOpenFileName, setPendingOpenFileName] = useState(null); + const openInspectorByFileName = useCallback((fileName: string) => { + if (!fileName) return; + setPendingOpenFileName(fileName); + setInspectorCollapsed(false); + writeInspectorCollapsed(false); + }, []); + const clearPendingOpenFileName = useCallback(() => setPendingOpenFileName(null), []); const resetInspectorView = useCallback(() => setInspectorView({ kind: 'overview' }), []); // When pod changes, drop any stale sub-page state. @@ -109,6 +122,7 @@ const V2Layout: React.FC = ({ selectionMode = 'auto' }) => { onToggleInspector={selectedPodId ? toggleInspector : undefined} onOpenMember={openInspectorMember} onOpenInvite={selectedPodId ? openInvite : undefined} + onOpenFile={openInspectorByFileName} /> {selectedPodId && !inspectorCollapsed && ( = ({ selectionMode = 'auto' }) => { onOpenArtifact={openInspectorArtifact} onBack={resetInspectorView} onOpenInvite={openInvite} + pendingOpenFileName={pendingOpenFileName} + onPendingOpenFileNameConsumed={clearPendingOpenFileName} /> )} {selectedPodId && detail.pod && ( diff --git a/frontend/src/v2/components/V2MessageBubble.tsx b/frontend/src/v2/components/V2MessageBubble.tsx index 9e107ee1..bdf5070d 100644 --- a/frontend/src/v2/components/V2MessageBubble.tsx +++ b/frontend/src/v2/components/V2MessageBubble.tsx @@ -81,6 +81,9 @@ interface V2MessageBubbleProps { // Clicking the author avatar / name opens the inspector to that member's // detail sub-page. Passed in by V2PodChat; only fires for agent authors. onAuthorClick?: (author: string) => void; + // Clicking a file pill routes to the inspector artifact preview by + // ObjectStore filename (or originalName for static demo tokens). + onOpenFile?: (fileName: string) => void; } interface ParsedFile { @@ -187,7 +190,10 @@ const parseReactions = (content: string): { stripped: string; reactions: ParsedR return { stripped, reactions }; }; -const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { +const FilePill: React.FC<{ + file: ParsedFile; + onOpenFile?: (fileName: string) => void; +}> = ({ file, onOpenFile }) => { const color = FILE_EXT_COLORS[file.ext] || '#94a3b8'; const inner = ( <> @@ -200,15 +206,43 @@ const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => { ); - // Static demo file (no backend reference) — render as a div, no click. + // Static demo file with no backend reference — but we can still try to + // resolve it via the inspector's pod-files index by `originalName`. The + // inspector's `openByFileName` callback (threaded down from V2Layout) is + // tolerant of either the ObjectStore key or a originalName lookup, so a + // chat author can post a `[[file:foo.md]]` static token and clicking it + // opens the corresponding pod-file artifact preview if a file with that + // originalName exists. Falls back to a non-clickable pill if there's no + // handler in scope. if (!file.fileName) { + if (onOpenFile) { + const handleStaticClick = (e: React.MouseEvent) => { + e.preventDefault(); + onOpenFile(file.name); // resolved by originalName + }; + return ( + + ); + } 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 — prefer the inspector route when a handler is in scope so + // the preview lands inline (markdown rendered, csv tabular, etc.) instead + // of dumping raw bytes into a new tab. Fall back to the legacy signed-URL + // open-in-tab when no handler is provided (older surfaces). const handleClick = async (e: React.MouseEvent) => { e.preventDefault(); + if (onOpenFile && file.fileName) { + onOpenFile(file.fileName); + return; + } const signed = await getSignedAttachmentUrl(`/api/uploads/${file.fileName}`); if (signed) { window.open(signed, '_blank', 'noopener,noreferrer'); @@ -241,7 +275,7 @@ const parseAgentDmEvent = (content: string | undefined): { headline: string; tar return { headline: match[1], targetPodId: match[2] }; }; -const V2MessageBubble: React.FC = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick }) => { +const V2MessageBubble: React.FC = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick, onOpenFile }) => { const { currentUser } = useAuth(); const navigate = useNavigate(); const rawUsername = message.user?.username || 'Unknown'; @@ -371,7 +405,7 @@ const V2MessageBubble: React.FC = ({ message, isLead, agen ) )} {files.map((file, idx) => ( - + ))} {prRefs.map((pr) => ( void; + // Click on an in-message file pill routes here. Passed straight through + // to V2MessageBubble → FilePill so the click opens the inspector + // artifact preview instead of window.open()'ing a raw file in a new tab. + onOpenFile?: (fileName: string) => void; } const Icon = ({ d }: { d: string }) => ( @@ -135,7 +139,7 @@ const Icon = ({ d }: { d: string }) => ( ); -const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite }) => { +const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile }) => { const { pod, members, messages, agents, sendMessage, loading, error } = detail; const navigate = useNavigate(); const api = useV2Api(); @@ -661,6 +665,7 @@ const V2PodChat: React.FC = ({ detail, inspectorCollapsed, onTog agentDisplayNames={agentDisplayNames} agentAuthorKeys={agentAuthorKeys} onAuthorClick={onOpenMember ? handleAuthorClick : undefined} + onOpenFile={onOpenFile} /> ))}
diff --git a/frontend/src/v2/components/V2PodInspector.tsx b/frontend/src/v2/components/V2PodInspector.tsx index 50b0084d..8dfc4b76 100644 --- a/frontend/src/v2/components/V2PodInspector.tsx +++ b/frontend/src/v2/components/V2PodInspector.tsx @@ -27,6 +27,15 @@ interface V2PodInspectorProps { // header invite icon, so the invite link survives switching between // the two trigger surfaces. onOpenInvite?: () => void; + // Click-on-file-pill flow. V2Layout sets this when a chat file pill is + // clicked; the inspector watches for the value to change, resolves it + // against its loaded podFiles list (matching either ObjectStore key OR + // originalName), opens the matching artifact sub-page, and clears the + // pending value via onPendingOpenFileNameConsumed. Falls back to a no-op + // if the file isn't in the pod's files index (avoid silent re-routes if + // the file name resolves nowhere). + pendingOpenFileName?: string | null; + onPendingOpenFileNameConsumed?: () => void; } // Any agent with a real chat runtime can hold a DM session. The Tier 1 @@ -483,6 +492,7 @@ const memberRoleLabel = ( const V2PodInspector: React.FC = ({ detail, podsState, view, onClose, onOpenMember, onOpenArtifact, onBack, onOpenInvite, + pendingOpenFileName, onPendingOpenFileNameConsumed, }) => { const { pod, members, agents } = detail; const api = useV2Api(); @@ -629,6 +639,23 @@ const V2PodInspector: React.FC = ({ return () => { cancelled = true; }; }, [pod?._id, api]); + // Resolve pending file-name → artifactId once podFiles is loaded. The + // chat file pill sets pendingOpenFileName via V2Layout; we match on + // either the ObjectStore key (`fileName`) or the human-facing original + // filename so static `[[file:foo.md]]` tokens that share an originalName + // with a real upload still route correctly. + useEffect(() => { + if (!pendingOpenFileName) return; + if (podFiles.length === 0) return; + const match = podFiles.find((f: { fileName?: string; originalName?: string }) => ( + f.fileName === pendingOpenFileName || f.originalName === pendingOpenFileName + )) as { _id?: string } | undefined; + if (match?._id) { + onOpenArtifact(`file-${match._id}`); + } + onPendingOpenFileNameConsumed?.(); + }, [pendingOpenFileName, podFiles, onOpenArtifact, onPendingOpenFileNameConsumed]); + if (!pod) return