Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/src/v2/components/V2Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ const V2Layout: React.FC<V2LayoutProps> = ({ 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<string | null>(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.
Expand Down Expand Up @@ -109,6 +122,7 @@ const V2Layout: React.FC<V2LayoutProps> = ({ selectionMode = 'auto' }) => {
onToggleInspector={selectedPodId ? toggleInspector : undefined}
onOpenMember={openInspectorMember}
onOpenInvite={selectedPodId ? openInvite : undefined}
onOpenFile={openInspectorByFileName}
/>
{selectedPodId && !inspectorCollapsed && (
<V2PodInspector
Expand All @@ -120,6 +134,8 @@ const V2Layout: React.FC<V2LayoutProps> = ({ selectionMode = 'auto' }) => {
onOpenArtifact={openInspectorArtifact}
onBack={resetInspectorView}
onOpenInvite={openInvite}
pendingOpenFileName={pendingOpenFileName}
onPendingOpenFileNameConsumed={clearPendingOpenFileName}
/>
)}
{selectedPodId && detail.pod && (
Expand Down
48 changes: 41 additions & 7 deletions frontend/src/v2/components/V2MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = (
<>
Expand All @@ -200,15 +206,43 @@ const FilePill: React.FC<{ file: ParsedFile }> = ({ file }) => {
</span>
</>
);
// 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 (
<button
type="button"
className="v2-msg__file v2-msg__file--clickable"
onClick={handleStaticClick}
aria-label={`Open ${file.name}`}
>
{inner}
</button>
);
}
return <span className="v2-msg__file">{inner}</span>;
}
// 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');
Expand Down Expand Up @@ -241,7 +275,7 @@ const parseAgentDmEvent = (content: string | undefined): { headline: string; tar
return { headline: match[1], targetPodId: match[2] };
};

const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick }) => {
const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick, onOpenFile }) => {
const { currentUser } = useAuth();
const navigate = useNavigate();
const rawUsername = message.user?.username || 'Unknown';
Expand Down Expand Up @@ -371,7 +405,7 @@ const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agen
)
)}
{files.map((file, idx) => (
<FilePill key={`${file.name}-${idx}`} file={file} />
<FilePill key={`${file.name}-${idx}`} file={file} onOpenFile={onOpenFile} />
))}
{prRefs.map((pr) => (
<V2GithubPrCard
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ interface V2PodChatProps {
// invite icon delegates to this so the chat path matches the inspector
// path and a single modal instance handles both surfaces.
onOpenInvite?: () => 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 }) => (
Expand All @@ -135,7 +139,7 @@ const Icon = ({ d }: { d: string }) => (
</svg>
);

const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite }) => {
const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile }) => {
const { pod, members, messages, agents, sendMessage, loading, error } = detail;
const navigate = useNavigate();
const api = useV2Api();
Expand Down Expand Up @@ -661,6 +665,7 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onTog
agentDisplayNames={agentDisplayNames}
agentAuthorKeys={agentAuthorKeys}
onAuthorClick={onOpenMember ? handleAuthorClick : undefined}
onOpenFile={onOpenFile}
/>
))}
<div ref={messagesEndRef} />
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/v2/components/V2PodInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -483,6 +492,7 @@ const memberRoleLabel = (

const V2PodInspector: React.FC<V2PodInspectorProps> = ({
detail, podsState, view, onClose, onOpenMember, onOpenArtifact, onBack, onOpenInvite,
pendingOpenFileName, onPendingOpenFileNameConsumed,
}) => {
const { pod, members, agents } = detail;
const api = useV2Api();
Expand Down Expand Up @@ -629,6 +639,23 @@ const V2PodInspector: React.FC<V2PodInspectorProps> = ({
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 <aside className="v2-pane v2-pane--inspector" />;

const isPrivatePod = pod.type === 'agent-room';
Expand Down
Loading