COD-38 document attachment previews + thumbnails (attachment cards)

Builds on the COD-37 registry: surfaces detected/registered attachments as
dismissible cards with a first-page thumbnail and an inline preview — the
consumer the registry PR deliberately deferred.

Backend:
- document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via
  pdftoppm; Office via the preview cache).
- document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice
  / PowerShell COM), in-flight dedup, multi-converter fallback.
- file-routes: serveConvertedPreview / serveThumbnail + four routes —
  GET .../attachments/:id/preview, .../thumbnail and the workspace-path
  file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe
  resolveServableAttachmentPath, so previews stream the freshly-resolved path.
- server: enrich detected attachment events with a thumbnail route.
- image-watcher: .png now routes to attachment:detected — this PR adds the card
  consumer, so the screenshot popup is no longer its only handler.

Frontend:
- panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all,
  per-session cleanup) plus a 3-arg openFilePreview that renders registered
  attachments inline (image/PDF) or via the server-converted PDF (docx/pptx).
- app.js: wire attachment:detected -> _onAttachmentDetected and card state.
- styles: attachment-card + stack styling.

Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer +
preview-cache unit tests pass; full test:ci green (2861 passed); card render +
preview overlay + dismiss verified in-browser.
This commit is contained in:
Aamer Akhter
2026-06-12 09:09:14 -04:00
parent 3f2c23cb0f
commit 49c92e4723
11 changed files with 1116 additions and 17 deletions
+208 -3
View File
@@ -2465,8 +2465,8 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
},
async openFilePreview(filePath) {
if (!this.activeSessionId || !filePath) return;
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
if (!sessionId || !filePath) return;
const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle');
@@ -2481,8 +2481,36 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
footerEl.textContent = '';
const ext = (filePath.split('.').pop() || '').toLowerCase();
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
footerEl.textContent = ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
} else if (ext === 'pdf') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else {
try {
const res = await fetch(`${base}/raw`);
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
}
return;
}
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
if (!res.ok) throw new Error('Failed to load file');
const result = await res.json();
@@ -2520,6 +2548,183 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = '';
},
// ═══════════════════════════════════════════════════════════════
// Attachment Cards (detected documents/images)
// ═══════════════════════════════════════════════════════════════
// SSE `attachment:detected` consumer: surface a dismissible card for the file.
_onAttachmentDetected(data) {
console.log('[Attachment Detected]', data);
this.addAttachmentCard(data);
},
// Lazily create the floating stack the cards live in (appended to <body>).
ensureAttachmentCardStack() {
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
if (!stack) {
stack = document.createElement('div');
stack.id = 'attachmentCardStack';
stack.className = 'attachment-card-stack';
document.body.appendChild(stack);
}
this.attachmentCardStack = stack;
return stack;
},
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
window.open(url, '_blank');
},
addAttachmentCard(attachmentEvent) {
const {
sessionId,
relativePath,
fileName,
timestamp,
size,
attachmentType,
extension,
attachmentId,
rawUrl,
previewUrl,
thumbnailUrl,
} = attachmentEvent;
const filePath = relativePath || fileName;
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
if (this.attachmentCards.has(cardId)) {
const existing = this.attachmentCards.get(cardId);
existing.element.focus?.();
return;
}
const MAX_ATTACHMENT_CARDS = 10;
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
const oldestId = this.attachmentCards.keys().next().value;
if (oldestId) this.closeAttachmentCard(oldestId);
}
const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl =
rawUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
const attachmentPreviewUrl =
previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
const attachmentThumbnailUrl =
thumbnailUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
const card = document.createElement('article');
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
card.tabIndex = 0;
card.dataset.attachmentId = cardId;
card.dataset.previewUrl = attachmentPreviewUrl || '';
card.innerHTML = `
<div class="attachment-thumbnail">
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
</div>
<div class="attachment-card-main">
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
<div class="attachment-file-meta">
<span>${escapeHtml(sessionName)}</span>
<span>${this.formatFileSize(size || 0)}</span>
</div>
<div class="attachment-actions">
<button type="button" class="attachment-preview-btn">Preview</button>
<a href="${escapeHtml(downloadUrl)}">Download</a>
<button type="button" class="attachment-open-btn">Open</button>
</div>
</div>
<button type="button" class="attachment-close-btn" title="Dismiss">&times;</button>
`;
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
if (attachmentThumbnailImg) {
attachmentThumbnailImg.onerror = () => {
attachmentThumbnailImg.remove();
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
};
}
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
this.openFilePreview(filePath, sessionId, attachmentId || null);
});
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
});
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
this.closeAttachmentCard(cardId);
});
stack.prepend(card);
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
this._refreshAttachmentClearAll();
},
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
_refreshAttachmentClearAll() {
const stack = this.attachmentCardStack;
if (!stack) return;
let control = stack.querySelector('.attachment-clear-all');
if (this.attachmentCards.size < 2) {
if (control) control.hidden = true;
return;
}
if (!control) {
control = document.createElement('button');
control.type = 'button';
control.className = 'attachment-clear-all';
control.textContent = 'Clear all';
control.title = 'Dismiss all attachment cards';
control.addEventListener('click', () => this.closeAllAttachmentCards());
stack.prepend(control);
}
control.hidden = false;
},
closeAttachmentCard(attachmentId) {
const cardData = this.attachmentCards.get(attachmentId);
if (!cardData) return;
cardData.element.remove();
this.attachmentCards.delete(attachmentId);
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
this.attachmentCardStack.remove();
this.attachmentCardStack = null;
} else {
this._refreshAttachmentClearAll();
}
},
closeAllAttachmentCards() {
for (const attachmentId of [...this.attachmentCards.keys()]) {
this.closeAttachmentCard(attachmentId);
}
},
closeSessionAttachmentCards(sessionId) {
const toClose = [];
for (const [attachmentId, data] of this.attachmentCards) {
if (data.sessionId === sessionId) toClose.push(attachmentId);
}
for (const attachmentId of toClose) {
this.closeAttachmentCard(attachmentId);
}
},
copyFilePreviewContent() {
if (this.filePreviewContent) {
navigator.clipboard.writeText(this.filePreviewContent).then(() => {