diff --git a/src/session-attachment-history.ts b/src/session-attachment-history.ts new file mode 100644 index 00000000..d63aa6e4 --- /dev/null +++ b/src/session-attachment-history.ts @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; +import { basename, extname } from 'node:path'; +import type { SessionAttachmentHistoryItem } from './types/session.js'; +import type { AttachmentDetectedEvent } from './types/tools.js'; +import { getAttachmentType } from './attachment-registry.js'; + +export const ATTACHMENT_HISTORY_LIMIT = 100; + +export interface ExternalAttachmentHistoryInput { + sessionId: string; + externalPath: string; + fileName?: string; + extension?: string; + size: number; + mtimeMs?: number; + timestamp?: number; +} + +export function normalizeAttachmentExtension(extensionOrPath: string): string { + const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath; + return value.toLowerCase().replace(/^\./, ''); +} + +function historyKey(item: SessionAttachmentHistoryItem): string { + if (item.source === 'external' && item.externalPath) { + return `external:${item.externalPath}`; + } + return `detected:${item.relativePath || item.fileName}`; +} + +function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string { + const source = item.externalPath || item.id || item.fileName; + const digest = createHash('sha256').update(source).digest('hex').slice(0, 16); + return `external:${digest}:${item.fileName}`; +} + +export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem { + const { externalPath: _externalPath, ...safe } = item; + return { + ...safe, + id: item.source === 'external' ? safeExternalHistoryId(item) : item.id, + }; +} + +export function sanitizeAttachmentHistory( + history: readonly SessionAttachmentHistoryItem[] +): SessionAttachmentHistoryItem[] { + return history.map(sanitizeAttachmentHistoryItem); +} + +export function upsertAttachmentHistory( + history: readonly SessionAttachmentHistoryItem[], + item: SessionAttachmentHistoryItem +): SessionAttachmentHistoryItem[] { + const nextKey = historyKey(item); + return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT); +} + +export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem { + return { + id: `detected:${event.relativePath || event.fileName}`, + sessionId: event.sessionId, + fileName: event.fileName, + extension: normalizeAttachmentExtension(event.extension), + attachmentType: event.attachmentType, + size: event.size, + mtimeMs: 0, + timestamp: event.timestamp, + source: 'detected', + relativePath: event.relativePath, + }; +} + +export function buildExternalAttachmentHistoryItem( + input: ExternalAttachmentHistoryInput +): SessionAttachmentHistoryItem { + const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath); + return { + id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${ + input.fileName || basename(input.externalPath) + }`, + sessionId: input.sessionId, + fileName: input.fileName || basename(input.externalPath), + extension, + attachmentType: getAttachmentType(extension), + size: input.size, + mtimeMs: input.mtimeMs ?? 0, + timestamp: input.timestamp ?? Date.now(), + source: 'external', + externalPath: input.externalPath, + }; +} diff --git a/src/session.ts b/src/session.ts index 1bd5d5f7..66de0950 100644 --- a/src/session.ts +++ b/src/session.ts @@ -81,6 +81,11 @@ import { SessionAutoOps } from './session-auto-ops.js'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; import { SessionTaskCache } from './session-task-cache.js'; import { parseAttachmentMagicLinks } from './attachment-magic.js'; +import { + sanitizeAttachmentHistory, + upsertAttachmentHistory as upsertAttachmentHistoryList, +} from './session-attachment-history.js'; +import type { SessionAttachmentHistoryItem } from './types/session.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; @@ -314,6 +319,7 @@ export class Session extends EventEmitter { // Bounded dedup set for terminal attachment magic-links already requested. private _attachmentMagicSeen = new Set(); + private _attachmentHistory: SessionAttachmentHistoryItem[] = []; // Nice prioritying configuration private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG }; @@ -405,6 +411,8 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Restored per-session attachment history. May include server-private external paths. */ + attachmentHistory?: SessionAttachmentHistoryItem[]; } ) { super(); @@ -471,6 +479,9 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } + if (config.attachmentHistory && config.attachmentHistory.length > 0) { + this.restoreAttachmentHistory(config.attachmentHistory); + } // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); @@ -897,6 +908,25 @@ export class Session extends EventEmitter { return this._status === 'idle' || this._status === 'busy'; } + get attachmentHistory(): SessionAttachmentHistoryItem[] { + return sanitizeAttachmentHistory(this._attachmentHistory); + } + + upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void { + this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item); + } + + restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void { + this._attachmentHistory = []; + for (const item of [...(history ?? [])].reverse()) { + this.upsertAttachmentHistory(item); + } + } + + getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined { + return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined; + } + toState(): SessionState { return { id: this.id, @@ -936,6 +966,7 @@ export class Session extends EventEmitter { codexConfig: this._codexConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, + attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined, // envOverrides intentionally NOT on the public SessionState type — they must not // leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which // can carry secrets). For disk persistence, session-manager calls diff --git a/src/types/session.ts b/src/types/session.ts index 61cf2b45..4cf742d9 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -25,6 +25,7 @@ */ import type { RespawnConfig } from './respawn.js'; +import type { AttachmentDetectedType } from './tools.js'; /** Status of a Claude session */ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error'; @@ -101,6 +102,40 @@ export interface SessionConfig { */ export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink'; +export type SessionAttachmentHistorySource = 'detected' | 'external'; + +/** + * Session-scoped attachment history entry. + * + * `externalPath` is server-private. It may be present in the internal persisted + * history copy, but API-bound session state must sanitize it before returning + * to the browser. + */ +export interface SessionAttachmentHistoryItem { + /** Stable history identity used for dedupe and list rendering */ + id: string; + /** Codeman session ID this item belongs to */ + sessionId: string; + /** Display filename */ + fileName: string; + /** Lowercase extension without a leading dot */ + extension: string; + /** Viewer category used by the web UI */ + attachmentType: AttachmentDetectedType; + /** File size in bytes */ + size: number; + /** Last modified timestamp in milliseconds, if known */ + mtimeMs: number; + /** Last time this attachment was seen or explicitly published */ + timestamp: number; + /** How the attachment entered the session */ + source: SessionAttachmentHistorySource; + /** Workspace-relative path for detected session files */ + relativePath?: string; + /** Server-private absolute path for explicitly published external files */ + externalPath?: string; +} + /** * Current state of a session */ @@ -183,6 +218,8 @@ export interface SessionState { resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Sanitized per-session attachment history. */ + attachmentHistory?: SessionAttachmentHistoryItem[]; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index fd4a88b5..21372c72 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -390,6 +390,9 @@ class CodemanApp { this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; this.attachmentCards = new Map(); // Map this.attachmentCardStack = null; + this.attachmentHistoryCounts = new Map(); // Map + this.attachmentHistoryItems = []; + this.attachmentHistoryDrawerOpen = false; // File browser state (methods in panels-ui.js) this.fileBrowserData = null; @@ -2222,6 +2225,7 @@ class CodemanApp { KeyboardHandler.init(); // Clear tab alerts this.tabAlerts.clear(); + this.attachmentHistoryCounts.clear(); // Clear shown completions (used for duplicate notification prevention) if (this._shownCompletions) { this._shownCompletions.clear(); @@ -3225,6 +3229,10 @@ class CodemanApp { // Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status this._updateActiveTabImmediate(sessionId); this.renderSessionTabs(); + this.updateAttachmentHistoryBadge?.(); + if (this.attachmentHistoryDrawerOpen) { + this.loadAttachmentHistory?.(sessionId); + } this._updateLocalEchoState(); // Restore flushed offset AND text IMMEDIATELY so backspace/typing work during @@ -3595,6 +3603,10 @@ class CodemanApp { this.projectInsights.delete(sessionId); this.pendingHooks.delete(sessionId); this.tabAlerts.delete(sessionId); + this.attachmentHistoryCounts.delete(sessionId); + if (this.attachmentHistoryDrawerOpen && this.activeSessionId === sessionId) { + this.closeAttachmentHistory?.(); + } this.terminalLoadStates.delete(sessionId); this.clearCountdownTimers(sessionId); this.closeSessionLogViewerWindows(sessionId); diff --git a/src/web/public/index.html b/src/web/public/index.html index a03b3e4a..f0987d35 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -114,6 +114,10 @@ +
—
+ + + +
+ `; + 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 = '
No active session
'; + this.updateAttachmentHistoryBadge(0); + return; + } + + list.innerHTML = '
Loading...
'; + 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 = `
Error: ${escapeHtml(err.message)}
`; + } + }, + + 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 = ` +
+
No attachments yet
+
Show a file here by running:
+ codeman attach /absolute/path/to/file.pptx +
Supports .pptx, .docx, .pdf, .png, .md, and .txt.
+
+ `; + 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 + ? `` + : ''; + const disabled = item.missing ? 'disabled aria-disabled="true"' : ''; + return ` +
+
+ ${thumb} +
${escapeHtml(typeLabel)}
+
+
+
${escapeHtml(item.fileName)}
+
${escapeHtml(meta)}
+
+ + + + +
+
+
+ `; + }, + + 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(() => { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 74d00e36..f65a3faa 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9401,6 +9401,216 @@ body.touch-device.cjk-input-visible .main { background: #fff; } +.attachment-history-badge { + position: absolute; + top: 2px; + right: 1px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: var(--accent); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +@keyframes notif-badge-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} + +/* Attachment History Drawer */ +.attachment-history-drawer { + position: fixed; + top: var(--header-height); + right: 0; + width: 390px; + max-width: calc(100vw - 24px); + height: calc(100vh - var(--header-height) - var(--toolbar-height)); + height: calc(100dvh - var(--header-height) - var(--toolbar-height)); + background: rgba(19, 19, 22, 0.98); + border-left: 1px solid var(--border); + z-index: 10000; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.18s ease; + box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36); +} + +.attachment-history-drawer.open { + transform: translateX(0); +} + +.attachment-history-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.attachment-history-title { + color: var(--text); + font-size: 0.9rem; + font-weight: 650; +} + +.attachment-history-subtitle { + margin-top: 2px; + color: var(--text-dim); + font-size: 0.68rem; +} + +.attachment-history-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.attachment-history-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.attachment-history-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 160px; + padding: 24px 16px; + color: var(--text-muted); + font-size: 0.78rem; + text-align: center; +} + +.attachment-history-empty-title { + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 600; +} + +.attachment-history-empty code { + max-width: 100%; + overflow-wrap: anywhere; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 5px 7px; + color: var(--text-primary); + background: var(--bg-tertiary); + font-size: 0.76rem; +} + +.attachment-history-item { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.attachment-history-item:last-child { + border-bottom: none; +} + +.attachment-history-item.missing { + opacity: 0.58; +} + +.attachment-history-thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 112px; + aspect-ratio: 16 / 9; + overflow: hidden; + border: 1px solid var(--border-light); + border-radius: 6px; + background: #f8f8fb; +} + +.attachment-history-thumb-img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + object-position: center; +} + +.attachment-history-thumb-fallback { + display: none; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + background: #20202a; + color: var(--text); + font-size: 0.72rem; + font-weight: 700; +} + +.attachment-history-thumb-fallback.visible { + display: flex; +} + +.attachment-history-item-main { + min-width: 0; +} + +.attachment-history-file-name { + overflow: hidden; + color: var(--text); + font-size: 0.8rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-history-meta { + margin-top: 2px; + color: var(--text-dim); + font-size: 0.67rem; +} + +.attachment-history-actions { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 7px; +} + +.attachment-history-actions button { + padding: 3px 7px; + border: 1px solid var(--border-light); + border-radius: 5px; + background: var(--bg-input); + color: var(--text); + font-size: 0.67rem; + cursor: pointer; +} + +.attachment-history-actions button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent-hover); +} + +.attachment-history-actions button:disabled { + opacity: 0.45; + cursor: default; +} + /* ============================================================ === v1.0 Carbon Aurora · Daylight overrides === Single appended block — trivially removable. Retones the hardcoded diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 3aeb46ae..c9638d56 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -11,15 +11,19 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types. import { fileStreamManager } from '../../file-stream-manager.js'; import { AttachmentRegistrationError, + attachmentRecordToEvent, attachmentRegistry, + buildFileThumbnailRoute, isSupportedAttachmentExtension, registerExternalAttachment, type AttachmentRecord, } from '../../attachment-registry.js'; import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; +import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js'; +import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; @@ -235,6 +239,133 @@ function getKnownSessionWorkingDir( return undefined; } +// Persisted sessions carry the private (externalPath-bearing) history under a +// `__attachmentHistory` key so the list route can re-register external files. +type StoredSessionWithPrivateAttachmentHistory = SessionState & { + __attachmentHistory?: SessionAttachmentHistoryItem[]; +}; + +type AttachmentHistoryRouteItem = Omit & { + missing: boolean; + rawUrl?: string; + url?: string; + previewUrl?: string; + thumbnailUrl?: string; + downloadUrl?: string; + attachmentId?: string; +}; + +function appendDownloadFlag(url: string): string { + return `${url}${url.includes('?') ? '&' : '?'}download=true`; +} + +function getSessionAttachmentHistory( + ctx: SessionPort & ConfigPort, + sessionId: string +): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined { + const liveSession = ctx.sessions.get(sessionId); + if (liveSession) { + return { + workingDir: liveSession.workingDir, + history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [], + }; + } + + const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined; + if (!stored) return undefined; + + return { + workingDir: stored.workingDir, + history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [], + }; +} + +// History item for a file detected inside the workspace: re-stat for live +// size/mtime and resolve preview/thumbnail/raw routes off the relative path. +async function buildDetectedAttachmentRouteItem( + sessionId: string, + workingDir: string, + item: SessionAttachmentHistoryItem +): Promise { + const safe = sanitizeAttachmentHistoryItem(item); + if (!item.relativePath) { + return { ...safe, missing: true }; + } + + const validated = validateSessionFilePath(workingDir, item.relativePath); + if (!validated) { + return { ...safe, missing: true }; + } + + let size = item.size; + let mtimeMs = item.mtimeMs; + try { + const stat = await fs.stat(validated.resolvedPath); + size = stat.size; + mtimeMs = stat.mtimeMs ?? mtimeMs; + } catch { + return { ...safe, missing: true }; + } + + const encodedPath = encodeURIComponent(item.relativePath); + const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`; + const previewUrl = + item.extension === 'docx' || item.extension === 'pptx' + ? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}` + : rawUrl; + const thumbnailUrl = isSupportedAttachmentExtension(item.extension) + ? buildFileThumbnailRoute(sessionId, item.relativePath) + : undefined; + + return { + ...safe, + size, + mtimeMs, + missing: false, + rawUrl, + url: rawUrl, + previewUrl, + thumbnailUrl, + downloadUrl: appendDownloadFlag(rawUrl), + }; +} + +// History item for an explicitly published external file: re-register it to mint +// a fresh id + by-id routes (the guard runs again), or mark it missing. +async function buildExternalAttachmentRouteItem( + sessionId: string, + item: SessionAttachmentHistoryItem, + sessionWorkingDir?: string +): Promise { + const safe = sanitizeAttachmentHistoryItem(item); + if (!item.externalPath) { + return { ...safe, missing: true }; + } + + try { + const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir }); + return { + ...safe, + fileName: event.fileName, + extension: event.extension, + attachmentType: event.attachmentType, + size: event.size, + missing: false, + attachmentId: event.attachmentId, + rawUrl: event.rawUrl, + url: event.rawUrl, + previewUrl: event.previewUrl, + thumbnailUrl: event.thumbnailUrl, + downloadUrl: appendDownloadFlag(event.rawUrl), + }; + } catch (err) { + if (err instanceof AttachmentRegistrationError) { + return { ...safe, missing: true }; + } + throw err; + } +} + export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { // File tree listing app.get('/api/sessions/:id/files', async (req) => { @@ -571,6 +702,69 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even } }); + // List a session's attachment history (live session or persisted), resolving + // each entry to current metadata + routes. External entries are re-registered. + app.get('/api/sessions/:id/attachments', async (req, reply) => { + const { id } = req.params as { id: string }; + const sessionHistory = getSessionAttachmentHistory(ctx, id); + if (!sessionHistory) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`)); + return; + } + + const items = await Promise.all( + sessionHistory.history.map((item) => + item.source === 'external' + ? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir) + : buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item) + ) + ); + + return { + success: true, + data: { + items, + count: items.length, + }, + }; + }); + + // Metadata poll for a single registered attachment (re-stats for live + // size/mtime as the underlying file is rewritten). + app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => { + const { id, attachmentId } = req.params as { id: string; attachmentId: string }; + const workingDir = getKnownSessionWorkingDir(ctx, id, reply); + if (!workingDir) return; + const record = getAttachmentOr404(reply, id, attachmentId); + if (!record) return; + if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return; + const event = attachmentRecordToEvent(record); + let size = record.size; + let mtimeMs = record.mtimeMs; + try { + const stat = await fs.stat(record.filePath); + size = stat.size; + mtimeMs = stat.mtimeMs ?? mtimeMs; + } catch { + // File temporarily unavailable mid-write — keep cached values. + } + return { + success: true, + data: { + path: record.fileName, + size, + mtimeMs, + type: record.attachmentType, + extension: record.extension, + url: event.rawUrl, + previewUrl: event.previewUrl, + thumbnailUrl: event.thumbnailUrl, + attachmentId: record.attachmentId, + fileName: record.fileName, + }, + }; + }); + // Serve the raw bytes of a registered attachment by id. Re-checks the // attachment-guard policy on every request (defense-in-depth) before streaming. app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => { diff --git a/src/web/server.ts b/src/web/server.ts index 8fc7f014..fa32a1df 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 })?.__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 diff --git a/test/session-attachment-history.test.ts b/test/session-attachment-history.test.ts new file mode 100644 index 00000000..ac83f6b0 --- /dev/null +++ b/test/session-attachment-history.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; +import type { SessionAttachmentHistoryItem } from '../src/types/session.js'; +import { + ATTACHMENT_HISTORY_LIMIT, + buildDetectedAttachmentHistoryItem, + buildExternalAttachmentHistoryItem, + upsertAttachmentHistory, +} from '../src/session-attachment-history.js'; + +describe('session attachment history', () => { + it('dedupes explicit external attachments by source path and moves latest to top', () => { + const first = buildExternalAttachmentHistoryItem({ + sessionId: 's1', + externalPath: '/mnt/c/docs/brief.docx', + fileName: 'brief.docx', + extension: 'docx', + size: 100, + mtimeMs: 1, + timestamp: 10, + }); + const second = { ...first, size: 200, mtimeMs: 2, timestamp: 20 }; + + const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + size: 200, + mtimeMs: 2, + timestamp: 20, + externalPath: '/mnt/c/docs/brief.docx', + }); + }); + + it('dedupes detected workspace attachments by relative path', () => { + const first = buildDetectedAttachmentHistoryItem({ + sessionId: 's1', + filePath: 'report.pdf', + relativePath: 'out/report.pdf', + fileName: 'report.pdf', + extension: 'pdf', + attachmentType: 'pdf', + size: 100, + timestamp: 10, + }); + const second = { ...first, size: 150, timestamp: 20 }; + + const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ relativePath: 'out/report.pdf', size: 150, timestamp: 20 }); + }); + + it('caps history to newest 100 items', () => { + let history: SessionAttachmentHistoryItem[] = []; + for (let i = 0; i < ATTACHMENT_HISTORY_LIMIT + 5; i++) { + history = upsertAttachmentHistory( + history, + buildDetectedAttachmentHistoryItem({ + sessionId: 's1', + filePath: `${i}.png`, + relativePath: `out/${i}.png`, + fileName: `${i}.png`, + extension: 'png', + attachmentType: 'image', + size: i, + timestamp: i, + }) + ); + } + + expect(history).toHaveLength(ATTACHMENT_HISTORY_LIMIT); + expect(history[0].fileName).toBe('104.png'); + expect(history.at(-1)?.fileName).toBe('5.png'); + }); + + it('includes attachment history in session state', () => { + const session = new Session({ workingDir: '/tmp' }); + session.upsertAttachmentHistory({ + id: 'detected:file.png', + sessionId: session.id, + fileName: 'file.png', + extension: 'png', + attachmentType: 'image', + size: 12, + mtimeMs: 0, + timestamp: 100, + source: 'detected', + relativePath: 'file.png', + }); + + expect(session.toState().attachmentHistory).toHaveLength(1); + expect(session.toState().attachmentHistory?.[0].fileName).toBe('file.png'); + }); + + it('sanitizes external attachment paths from public session state', () => { + const session = new Session({ workingDir: '/tmp' }); + session.upsertAttachmentHistory( + buildExternalAttachmentHistoryItem({ + sessionId: session.id, + externalPath: '/mnt/c/private/board-update.pdf', + fileName: 'board-update.pdf', + extension: 'pdf', + size: 100, + timestamp: 100, + }) + ); + + const publicState = session.toState(); + const persistedHistory = session.getAttachmentHistoryForPersist(); + + expect(JSON.stringify(publicState.attachmentHistory)).not.toContain('/mnt/c/private/board-update.pdf'); + expect(publicState.attachmentHistory?.[0].id).not.toContain('/mnt/c/private/board-update.pdf'); + expect(persistedHistory?.[0].externalPath).toBe('/mnt/c/private/board-update.pdf'); + }); +});