Skip to content
Open
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
13 changes: 11 additions & 2 deletions backend/__tests__/unit/routes/uploads.post.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions backend/routes/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/v2/components/V2Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const V2Layout: React.FC<V2LayoutProps> = ({ selectionMode = 'auto' }) => {
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={selectedPodId ? toggleInspector : undefined}
onOpenMember={openInspectorMember}
onOpenArtifact={openInspectorArtifact}
/>
{selectedPodId && !inspectorCollapsed && (
<V2PodInspector
Expand Down
112 changes: 90 additions & 22 deletions frontend/src/v2/components/V2MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,25 @@ 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 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 {
Expand Down Expand Up @@ -122,11 +131,13 @@ const FILE_EXT_COLORS: Record<string, string> = {
// 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>|<originalName>|<size>|<kind>]]
// 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:<fileName>|<originalName>|<size>|<kind>]] (legacy)
// [[upload:<fileName>|<originalName>|<size>|<kind>|<fileId>]] (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 '';
Expand All @@ -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) => {
Expand All @@ -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<string, string> = {
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) => {
Expand All @@ -185,28 +241,40 @@ 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 = (
<>
<span className="v2-msg__file-icon" style={{ background: color }}>
{file.ext.slice(0, 4).toUpperCase()}
</span>
<span className="v2-msg__file-meta">
<span className="v2-msg__file-name">{file.name}</span>
{file.size && <span className="v2-msg__file-size">{file.size}</span>}
{subline && <span className="v2-msg__file-sub">{subline}</span>}
</span>
</>
);
// Static demo file (no backend reference) — render as a div, no click.
if (!file.fileName) {
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 — 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');
Expand All @@ -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}
</button>
);
};

const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick }) => {
const V2MessageBubble: React.FC<V2MessageBubbleProps> = ({ message, isLead, agentDisplayNames, agentAuthorKeys, onAuthorClick, onArtifactClick }) => {
const { currentUser } = useAuth();
const rawUsername = message.user?.username || 'Unknown';
const overriddenDisplay = agentDisplayNames?.get(rawUsername);
Expand Down Expand Up @@ -299,7 +367,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} onArtifactClick={onArtifactClick} />
))}
{reactions.length > 0 && (
<div className="v2-msg__reactions" aria-label="Reactions">
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand All @@ -130,7 +134,7 @@ const Icon = ({ d }: { d: string }) => (
</svg>
);

const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember }) => {
const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenArtifact }) => {
const { pod, members, messages, agents, sendMessage, loading, error } = detail;
const navigate = useNavigate();
const api = useV2Api();
Expand Down Expand Up @@ -470,6 +474,7 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ 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;
Expand All @@ -483,7 +488,18 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ 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) {
Expand Down Expand Up @@ -611,6 +627,7 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ detail, inspectorCollapsed, onTog
agentDisplayNames={agentDisplayNames}
agentAuthorKeys={agentAuthorKeys}
onAuthorClick={onOpenMember ? handleAuthorClick : undefined}
onArtifactClick={onOpenArtifact}
/>
))}
<div ref={messagesEndRef} />
Expand Down
50 changes: 37 additions & 13 deletions frontend/src/v2/components/V2PodInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -994,15 +994,29 @@ const V2PodInspector: React.FC<V2PodInspectorProps> = ({
// 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 + `<a download>` 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 <a download> 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);
Expand Down Expand Up @@ -1030,13 +1044,23 @@ const V2PodInspector: React.FC<V2PodInspectorProps> = ({
/>
{openable && (
<div className="v2-inspector__detail-actions">
<button
type="button"
className="v2-inspector__btn v2-inspector__btn--primary"
onClick={() => { void handleOpenArtifact(found); }}
>
Open
</button>
{found.fileName ? (
<button
type="button"
className="v2-inspector__btn v2-inspector__btn--primary"
onClick={() => { void handleDownloadFile(found); }}
>
Download
</button>
) : (
<button
type="button"
className="v2-inspector__btn v2-inspector__btn--primary"
onClick={() => handleOpenUrl(found)}
>
Open in new tab
</button>
)}
</div>
)}
{found.subtitle && (
Expand Down
Loading
Loading