mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
|
||||
// Nice prioritying configuration
|
||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
||||
@@ -405,6 +411,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** 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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -390,6 +390,9 @@ class CodemanApp {
|
||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
|
||||
this.attachmentCardStack = null;
|
||||
this.attachmentHistoryCounts = new Map(); // Map<sessionId, count>
|
||||
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);
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
|
||||
@@ -2346,3 +2346,47 @@ html.mobile-init .file-browser-panel {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
/* Attachment history (COD-18): full-screen sheet on phones */
|
||||
.attachment-history-drawer {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
border-left: none;
|
||||
border-radius: 0;
|
||||
padding-top: var(--safe-area-top);
|
||||
padding-left: var(--safe-area-left);
|
||||
padding-right: var(--safe-area-right);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
}
|
||||
|
||||
.attachment-history-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.attachment-history-list {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.attachment-history-item {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.attachment-history-thumb {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.attachment-history-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attachment-history-actions button {
|
||||
padding: 4px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
+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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SessionAttachmentHistoryItem, 'externalPath'> & {
|
||||
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<AttachmentHistoryRouteItem> {
|
||||
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<AttachmentHistoryRouteItem> {
|
||||
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) => {
|
||||
|
||||
+44
-5
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user