mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
COD-39 attachment history drawer
Stacks on COD-38: accumulates a per-session attachment history and exposes it through a slide-in drawer with an unread badge, so attachments stay reachable after their cards are dismissed. Backend: - session-attachment-history: history state — dedupe by source path / relative path, newest-first, 100-item cap, and externalPath sanitization (the absolute host path is server-private and never leaves toState()). - session.ts: _attachmentHistory + getter (sanitized) / upsert / restore / getAttachmentHistoryForPersist; restored from saved state in the constructor. - file-routes: GET /attachments (list — resolves each entry to live metadata + routes; external entries are re-registered) and GET /attachments/:id (metadata poll). The by-id route guards via the registry's TOCTOU-safe resolveServableAttachmentPath. - server.ts: detected/registered attachments upsert into history and persist; the private (externalPath-bearing) history rides on disk under __attachmentHistory, separate from the sanitized public copy, and is restored on mux-session recovery. - types/session.ts: SessionAttachmentHistoryItem + SessionState.attachmentHistory. Frontend: - panels-ui: the drawer (lazy-built), unread badge, list render with per-item preview/download/open/"Card" (reshow) actions, and live refresh of the open drawer on new detections. - app.js: history state + per-session badge/cleanup wiring. - index.html / styles.css / mobile.css: header button + badge and the drawer. Verified: tsc / eslint / prettier / frontend-syntax / public-assets clean; new history-module unit tests pass; full test:ci green (2866 passed); badge, drawer open/render/reshow/close verified in-browser.
This commit is contained in:
committed by
Claude (Codeman maintainer)
parent
5eacb1cf03
commit
577b6d7384
+241
-1
@@ -2547,10 +2547,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Attachment Cards (detected documents/images)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// SSE `attachment:detected` consumer: surface a dismissible card for the file.
|
||||
// SSE `attachment:detected` consumer: surface a dismissible card for the file
|
||||
// and bump the per-session history unread count (refreshing the open drawer).
|
||||
_onAttachmentDetected(data) {
|
||||
console.log('[Attachment Detected]', data);
|
||||
this.addAttachmentCard(data);
|
||||
if (data.sessionId) {
|
||||
const current =
|
||||
this.attachmentHistoryCounts.get(data.sessionId) ??
|
||||
this.sessions.get(data.sessionId)?.attachmentHistory?.length ??
|
||||
0;
|
||||
this.attachmentHistoryCounts.set(data.sessionId, Math.min(current + 1, 100));
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.updateAttachmentHistoryBadge();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this._debouncedCall('attachmentHistoryRefresh', () => this.loadAttachmentHistory(data.sessionId), 250);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Lazily create the floating stack the cards live in (appended to <body>).
|
||||
@@ -2720,6 +2734,232 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment History Drawer
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
updateAttachmentHistoryBadge(count = null) {
|
||||
const badge = document.getElementById('attachmentHistoryBadge');
|
||||
const button = document.getElementById('attachmentsHistoryBtn');
|
||||
const sessionId = this.activeSessionId;
|
||||
const nextCount = count ?? (sessionId ? this.attachmentHistoryCounts.get(sessionId) || 0 : 0);
|
||||
if (badge) {
|
||||
badge.textContent = String(Math.min(nextCount, 99));
|
||||
badge.style.display = nextCount > 0 ? '' : 'none';
|
||||
}
|
||||
if (button) {
|
||||
button.classList.toggle('active', this.attachmentHistoryDrawerOpen);
|
||||
button.setAttribute('aria-expanded', this.attachmentHistoryDrawerOpen ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
ensureAttachmentHistoryDrawer() {
|
||||
let drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
if (drawer) return drawer;
|
||||
|
||||
drawer = document.createElement('aside');
|
||||
drawer.id = 'attachmentHistoryDrawer';
|
||||
drawer.className = 'attachment-history-drawer';
|
||||
drawer.setAttribute('aria-label', 'Attachment history');
|
||||
drawer.innerHTML = `
|
||||
<div class="attachment-history-header">
|
||||
<div>
|
||||
<div class="attachment-history-title">Attachments</div>
|
||||
<div class="attachment-history-subtitle" id="attachmentHistorySubtitle">0 files</div>
|
||||
</div>
|
||||
<div class="attachment-history-header-actions">
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryRefreshBtn" title="Refresh" aria-label="Refresh attachments">↻</button>
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryCloseBtn" title="Close" aria-label="Close attachments">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-history-list" id="attachmentHistoryList"></div>
|
||||
`;
|
||||
document.body.appendChild(drawer);
|
||||
drawer.querySelector('#attachmentHistoryRefreshBtn')?.addEventListener('click', () => {
|
||||
this.loadAttachmentHistory(this.activeSessionId);
|
||||
});
|
||||
drawer.querySelector('#attachmentHistoryCloseBtn')?.addEventListener('click', () => {
|
||||
this.closeAttachmentHistory();
|
||||
});
|
||||
return drawer;
|
||||
},
|
||||
|
||||
async toggleAttachmentHistory() {
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this.closeAttachmentHistory();
|
||||
return;
|
||||
}
|
||||
await this.openAttachmentHistory();
|
||||
},
|
||||
|
||||
async openAttachmentHistory() {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
this.attachmentHistoryDrawerOpen = true;
|
||||
drawer.classList.add('open');
|
||||
this.updateAttachmentHistoryBadge();
|
||||
await this.loadAttachmentHistory(this.activeSessionId);
|
||||
},
|
||||
|
||||
closeAttachmentHistory() {
|
||||
const drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
this.attachmentHistoryDrawerOpen = false;
|
||||
drawer?.classList.remove('open');
|
||||
this.updateAttachmentHistoryBadge();
|
||||
},
|
||||
|
||||
async loadAttachmentHistory(sessionId = this.activeSessionId) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
if (!sessionId) {
|
||||
this.attachmentHistoryItems = [];
|
||||
subtitle.textContent = 'No session';
|
||||
list.innerHTML = '<div class="attachment-history-empty">No active session</div>';
|
||||
this.updateAttachmentHistoryBadge(0);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '<div class="attachment-history-empty">Loading...</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/attachments`);
|
||||
if (!res.ok) throw new Error('Failed to load attachments');
|
||||
const result = await res.json();
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load attachments');
|
||||
const items = result.data?.items || [];
|
||||
this.attachmentHistoryItems = items;
|
||||
this.attachmentHistoryCounts.set(sessionId, items.length);
|
||||
this.updateAttachmentHistoryBadge(items.length);
|
||||
this.renderAttachmentHistory(items);
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachment history:', err);
|
||||
subtitle.textContent = 'Unavailable';
|
||||
list.innerHTML = `<div class="attachment-history-empty">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderAttachmentHistory(items = this.attachmentHistoryItems || []) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
subtitle.textContent = `${items.length} ${items.length === 1 ? 'file' : 'files'}`;
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="attachment-history-empty">
|
||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||
<div>Show a file here by running:</div>
|
||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map((item) => this.renderAttachmentHistoryItem(item)).join('');
|
||||
list.querySelectorAll('.attachment-history-thumb-img').forEach((img) => {
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = img.closest('.attachment-history-thumb')?.querySelector('.attachment-history-thumb-fallback');
|
||||
fallback?.classList.add('visible');
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('[data-attachment-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-history-id');
|
||||
const action = button.getAttribute('data-attachment-action');
|
||||
if (!id || !action) return;
|
||||
if (action === 'preview') this.previewAttachmentHistoryItem(id);
|
||||
if (action === 'download') this.downloadAttachmentHistoryItem(id);
|
||||
if (action === 'open') this.openAttachmentHistoryItem(id);
|
||||
if (action === 'reshow') this.reshowAttachmentCard(id);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderAttachmentHistoryItem(item) {
|
||||
const typeLabel = (item.extension || item.attachmentType || 'file').toUpperCase();
|
||||
const meta = [
|
||||
item.source === 'external' ? 'published' : 'workspace',
|
||||
this.formatFileSize(item.size || 0),
|
||||
item.missing ? 'missing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
<div class="attachment-history-item ${item.missing ? 'missing' : ''}" data-history-item="${escapeHtml(item.id)}">
|
||||
<div class="attachment-history-thumb">
|
||||
${thumb}
|
||||
<div class="attachment-history-thumb-fallback ${thumb ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||
</div>
|
||||
<div class="attachment-history-item-main">
|
||||
<div class="attachment-history-file-name" title="${escapeHtml(item.fileName)}">${escapeHtml(item.fileName)}</div>
|
||||
<div class="attachment-history-meta">${escapeHtml(meta)}</div>
|
||||
<div class="attachment-history-actions">
|
||||
<button type="button" data-attachment-action="preview" data-history-id="${escapeHtml(item.id)}" ${disabled}>Preview</button>
|
||||
<button type="button" data-attachment-action="download" data-history-id="${escapeHtml(item.id)}" ${disabled}>Download</button>
|
||||
<button type="button" data-attachment-action="open" data-history-id="${escapeHtml(item.id)}" ${disabled}>Open</button>
|
||||
<button type="button" data-attachment-action="reshow" data-history-id="${escapeHtml(item.id)}" ${disabled}>Card</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getAttachmentHistoryItem(itemId) {
|
||||
return (this.attachmentHistoryItems || []).find((item) => item.id === itemId) || null;
|
||||
},
|
||||
|
||||
previewAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
const path = item.relativePath || item.fileName;
|
||||
this.openFilePreview(path, item.sessionId, item.attachmentId || null);
|
||||
// Close the drawer so the preview window is unobstructed.
|
||||
this.closeAttachmentHistory();
|
||||
},
|
||||
|
||||
openAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
},
|
||||
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
this.addAttachmentCard({
|
||||
sessionId: item.sessionId,
|
||||
relativePath: item.relativePath,
|
||||
fileName: item.fileName,
|
||||
timestamp: Date.now(),
|
||||
size: item.size,
|
||||
attachmentType: item.attachmentType,
|
||||
extension: item.extension,
|
||||
attachmentId: item.attachmentId,
|
||||
rawUrl: item.rawUrl,
|
||||
previewUrl: item.previewUrl,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
});
|
||||
},
|
||||
|
||||
copyFilePreviewContent() {
|
||||
if (this.filePreviewContent) {
|
||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||
|
||||
Reference in New Issue
Block a user