Merge PR #120: document attachment previews + thumbnails (COD-38)

Adds attachment cards with first-page thumbnails and inline document
previews (PDF/Office via pdftoppm + LibreOffice), plus review hardening
(converter concurrency limiter, bounded preview cache, fixed detected-doc
preview routing). See PR #120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/web/public/styles.css
This commit is contained in:
Claude (Codeman maintainer)
2026-06-14 08:49:36 +02:00
15 changed files with 1509 additions and 17 deletions
+4
View File
@@ -223,6 +223,7 @@ const _SSE_HANDLER_MAP = [
// Images
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
// Tunnel
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
@@ -387,6 +388,8 @@ class CodemanApp {
// Image popup windows (auto-open for detected screenshots/images)
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
this.attachmentCardStack = null;
// File browser state (methods in panels-ui.js)
this.fileBrowserData = null;
@@ -3596,6 +3599,7 @@ class CodemanApp {
this.clearCountdownTimers(sessionId);
this.closeSessionLogViewerWindows(sessionId);
this.closeSessionImagePopups(sessionId);
this.closeSessionAttachmentCards(sessionId);
this.closeSessionSubagentWindows(sessionId, true);
// Clean up idle timer
+225 -3
View File
@@ -2443,8 +2443,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');
@@ -2459,8 +2459,53 @@ 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;
}
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
// converted to PDF server-side via the file-preview route; PDFs stream raw.
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
// to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase();
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
if (ext === 'pdf') {
footerEl.textContent = 'PDF';
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
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();
@@ -2498,6 +2543,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(() => {
+169
View File
@@ -9232,6 +9232,175 @@ body.touch-device.cjk-input-visible .main {
z-index: 100;
}
.attachment-card-stack {
position: fixed;
right: 18px;
bottom: calc(var(--toolbar-height) + 18px);
z-index: 1900;
display: flex;
flex-direction: column;
gap: 8px;
width: min(540px, calc(100vw - 32px));
pointer-events: none;
}
.attachment-clear-all {
align-self: flex-end;
padding: 3px 10px;
border: 1px solid var(--border-light);
border-radius: 5px;
background: rgba(19, 19, 22, 0.96);
color: var(--text-dim);
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
pointer-events: auto;
}
.attachment-clear-all:hover {
border-color: var(--accent);
color: var(--accent-hover);
}
.attachment-clear-all[hidden] {
display: none;
}
.attachment-card {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) 24px;
gap: 10px;
align-items: center;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(19, 19, 22, 0.96);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
pointer-events: auto;
}
.attachment-thumbnail {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: auto;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 6px;
background: #f8f8fb;
border: 1px solid var(--border-light);
}
.attachment-thumbnail-img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.attachment-thumbnail-fallback {
display: none;
align-items: center;
justify-content: center;
position: absolute;
inset: 0;
color: var(--text);
font-size: 0.7rem;
font-weight: 700;
background: #20202a;
}
.attachment-thumbnail-fallback.visible {
display: flex;
}
.attachment-type-badge {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 6px;
background: #20202a;
color: var(--text);
border: 1px solid var(--border-light);
font-size: 0.7rem;
font-weight: 700;
}
.attachment-card-main {
min-width: 0;
}
.attachment-file-name {
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-file-meta {
display: flex;
gap: 8px;
margin-top: 2px;
color: var(--text-dim);
font-size: 0.68rem;
}
.attachment-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 7px;
}
.attachment-actions button,
.attachment-actions a {
padding: 3px 8px;
border: 1px solid var(--border-light);
border-radius: 5px;
background: var(--bg-input);
color: var(--text);
text-decoration: none;
font-size: 0.68rem;
cursor: pointer;
}
.attachment-actions button:hover,
.attachment-actions a:hover {
border-color: var(--accent);
color: var(--accent-hover);
}
.attachment-close-btn {
align-self: start;
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
}
.attachment-close-btn:hover {
color: var(--text);
}
/* PDF/Office previews render in an iframe inside the preview body. */
.file-preview-body iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
/* ============================================================
=== v1.0 Carbon Aurora · Daylight overrides ===
Single appended block — trivially removable. Retones the hardcoded