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:
Aamer Akhter
2026-06-14 09:05:17 +02:00
committed by Claude (Codeman maintainer)
parent 5eacb1cf03
commit 577b6d7384
11 changed files with 1025 additions and 6 deletions
+44 -5
View File
@@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
@@ -61,6 +61,10 @@ import {
} from '../subagent-watcher.js';
import { imageWatcher } from '../image-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import {
buildDetectedAttachmentHistoryItem,
buildExternalAttachmentHistoryItem,
} from '../session-attachment-history.js';
import { TranscriptWatcher } from '../transcript-watcher.js';
import { TeamWatcher } from '../team-watcher.js';
import { TunnelManager } from '../tunnel-manager.js';
@@ -441,13 +445,20 @@ export class WebServer extends EventEmitter {
// Store handlers for cleanup on shutdown
this.imageWatcherHandlers = {
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
attachmentDetected: (event: AttachmentDetectedEvent) =>
this.broadcast(SseEvent.AttachmentDetected, {
attachmentDetected: (event: AttachmentDetectedEvent) => {
const attachmentEvent = {
...event,
source: event.source || 'detected',
thumbnailUrl:
event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName),
}),
};
const session = this.sessions.get(event.sessionId);
if (session) {
session.upsertAttachmentHistory(buildDetectedAttachmentHistoryItem(attachmentEvent));
this.persistSessionState(session);
}
this.broadcast(SseEvent.AttachmentDetected, attachmentEvent);
},
error: (error: Error, sessionId?: string) => {
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
},
@@ -909,7 +920,14 @@ export class WebServer extends EventEmitter {
// field kept off SessionState to avoid leaking via API broadcasts.
const base = session.toState();
const envOverrides = session.getEnvOverridesForPersist();
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
// separate from the sanitized public attachmentHistory in toState().
const attachmentHistory = session.getAttachmentHistoryForPersist();
const state = {
...base,
...(envOverrides ? { __envOverrides: envOverrides } : {}),
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
} as SessionState;
const controller = this.respawnControllers.get(session.id);
if (controller) {
const config = controller.getConfig();
@@ -1300,6 +1318,21 @@ export class WebServer extends EventEmitter {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
const record = attachmentRegistry.get(sessionId, event.attachmentId);
if (record) {
session.upsertAttachmentHistory(
buildExternalAttachmentHistoryItem({
sessionId,
externalPath: record.filePath,
fileName: record.fileName,
extension: record.extension,
size: record.size,
mtimeMs: record.mtimeMs,
timestamp: event.timestamp,
})
);
this.persistSessionState(session);
}
this.broadcast(SseEvent.AttachmentDetected, event);
}
@@ -2018,6 +2051,11 @@ export class WebServer extends EventEmitter {
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
// by the Session constructor (env var would hard-lock /effort switching).
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
// Prefer the private (externalPath-bearing) history; fall back to the
// sanitized public copy for sessions persisted before that split.
const savedAttachmentHistory =
(savedState as { __attachmentHistory?: SessionAttachmentHistoryItem[] })?.__attachmentHistory ??
savedState?.attachmentHistory;
const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID
workingDir: muxSession.workingDir,
@@ -2030,6 +2068,7 @@ export class WebServer extends EventEmitter {
allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name