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
+7 -4
View File
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
// ========== Image Detection ==========
describe('image detection', () => {
it('should emit image:detected (popup) for .png files', () => {
it('should emit attachment:detected for .png files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.on('attachment:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
expect(event.fileName).toBe('screenshot.png');
expect(event.filePath).toBe('/home/user/project/screenshot.png');
expect(event.relativePath).toBe('screenshot.png');
expect(event.extension).toBe('png');
expect(event.attachmentType).toBe('image');
expect(event.size).toBe(2048);
});
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
it('should not emit legacy image:detected for .png attachment cards', () => {
const handler = vi.fn();
watcher.on('attachment:detected', handler);
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');