diff --git a/packages/client/src/export/export-actions.ts b/packages/client/src/export/export-actions.ts new file mode 100644 index 0000000..a985a55 --- /dev/null +++ b/packages/client/src/export/export-actions.ts @@ -0,0 +1,49 @@ +/** + * Export Actions — Clipboard copy and file download helpers. + * Extracted from SessionExport for shared use across export surfaces. + */ + +import type { ExportableSession } from './session-export-formatter.js'; + +/** Copy text to clipboard with textarea fallback */ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback for older browsers / non-HTTPS + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + return true; + } catch { + return false; + } finally { + textarea.remove(); + } + } +} + +/** Download content as a file */ +export function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** Generate a timestamped export filename */ +export function generateExportFilename(session: ExportableSession, format: 'md' | 'json'): string { + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const project = session.projectName.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30); + const ext = format === 'md' ? '.md' : '.json'; + return `agentmove-${project}-${timestamp}${ext}`; +} diff --git a/packages/client/src/export/session-data-adapter.ts b/packages/client/src/export/session-data-adapter.ts new file mode 100644 index 0000000..bf4249a --- /dev/null +++ b/packages/client/src/export/session-data-adapter.ts @@ -0,0 +1,191 @@ +/** + * Session Data Adapter — Maps live and recorded session data + * into the unified ExportableSession shape used by formatters. + */ + +import type { RecordedSession, RecordedTimelineEvent, AgentState, ActivityEntry } from '@agent-move/shared'; +import { computeAgentCost, ZONE_MAP } from '@agent-move/shared'; +import type { ExportableSession, ExportableAgent, ExportableTimelineEvent } from './session-export-formatter.js'; + +// ─── Recorded Session Adapter ───────────────────────────────── + +/** Adapt a recorded session + timeline into ExportableSession */ +export function adaptRecordedSession( + session: RecordedSession, + timeline: RecordedTimelineEvent[], + resolveAgentName: (agentId: string) => string, +): ExportableSession { + const agents: ExportableAgent[] = session.agents.map(ag => ({ + agentId: ag.agentId, + name: resolveAgentName(ag.agentId), + role: ag.role, + model: ag.model, + cost: ag.cost, + totalInputTokens: ag.totalInputTokens, + totalOutputTokens: ag.totalOutputTokens, + cacheReadTokens: ag.cacheReadTokens, + cacheCreationTokens: ag.cacheCreationTokens, + toolUseCount: ag.toolUseCount, + durationMs: ag.endedAt - ag.spawnedAt, + })); + + const startedAt = session.startedAt; + const timelineEvents: ExportableTimelineEvent[] = timeline.map(e => ({ + timestamp: e.timestamp, + elapsedMs: e.timestamp - startedAt, + agentName: resolveAgentName(e.agentId), + kind: e.kind, + zone: e.zone ? (ZONE_MAP.get(e.zone)?.label ?? e.zone) : undefined, + tool: e.tool, + toolArgs: e.toolArgs, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + })); + + // Tool counts from toolchain data or from timeline + const toolCounts: Record = session.toolChain?.toolCounts + ? { ...session.toolChain.toolCounts } + : buildToolCountsFromTimeline(timeline); + + return { + source: session.source, + projectName: session.projectName, + projectPath: session.projectPath, + rootSessionId: session.rootSessionId, + model: session.model, + label: session.label, + startedAt: session.startedAt, + endedAt: session.endedAt, + durationMs: session.durationMs, + totalCost: session.totalCost, + totalInputTokens: session.totalInputTokens, + totalOutputTokens: session.totalOutputTokens, + totalCacheReadTokens: session.totalCacheReadTokens, + totalCacheCreationTokens: session.totalCacheCreationTokens, + totalToolUses: session.totalToolUses, + agents, + timeline: timelineEvents, + toolCounts, + }; +} + +function buildToolCountsFromTimeline(timeline: RecordedTimelineEvent[]): Record { + const counts: Record = {}; + for (const e of timeline) { + if (e.kind === 'tool' && e.tool) { + counts[e.tool] = (counts[e.tool] ?? 0) + 1; + } + } + return counts; +} + +// ─── Live Session Adapter ───────────────────────────────────── + +/** Adapt live session data into ExportableSession */ +export function adaptLiveSession( + liveAgents: AgentState[], + shutdownTotals: { cost: number; input: number; output: number; tools: number }, + activityEntries: Map, + resolveAgentName: (agentId: string) => string, +): ExportableSession { + const now = Date.now(); + + // Determine earliest spawn time for session start + const startedAt = liveAgents.length > 0 + ? Math.min(...liveAgents.map(a => a.spawnedAt)) + : now; + + // Build per-agent export data + const agents: ExportableAgent[] = liveAgents.map(ag => { + const cost = computeAgentCost(ag); + const status: 'active' | 'idle' | 'done' = ag.isDone ? 'done' : ag.isIdle ? 'idle' : 'active'; + return { + agentId: ag.id, + name: resolveAgentName(ag.id), + role: ag.role, + model: ag.model ?? null, + cost, + totalInputTokens: ag.totalInputTokens ?? 0, + totalOutputTokens: ag.totalOutputTokens ?? 0, + cacheReadTokens: ag.cacheReadTokens ?? 0, + cacheCreationTokens: ag.cacheCreationTokens ?? 0, + toolUseCount: ag.toolUseCount ?? 0, + durationMs: now - ag.spawnedAt, + status, + }; + }); + + // Aggregate totals (live agents + shutdown agents) + let totalCost = shutdownTotals.cost; + let totalInput = shutdownTotals.input; + let totalOutput = shutdownTotals.output; + let totalToolUses = shutdownTotals.tools; + let totalCacheRead = 0; + let totalCacheCreation = 0; + const models = new Set(); + + for (const ag of liveAgents) { + totalCost += computeAgentCost(ag); + totalInput += ag.totalInputTokens ?? 0; + totalOutput += ag.totalOutputTokens ?? 0; + totalCacheRead += ag.cacheReadTokens ?? 0; + totalCacheCreation += ag.cacheCreationTokens ?? 0; + totalToolUses += ag.toolUseCount ?? 0; + if (ag.model) models.add(ag.model); + } + + // Build timeline from activity entries + const allEntries: ExportableTimelineEvent[] = []; + for (const [agentId, entries] of activityEntries) { + const agentName = resolveAgentName(agentId); + for (const e of entries) { + allEntries.push({ + timestamp: e.timestamp, + elapsedMs: e.timestamp - startedAt, + agentName, + kind: e.kind, + zone: e.zone ? (ZONE_MAP.get(e.zone)?.label ?? e.zone) : undefined, + tool: e.tool, + toolArgs: e.toolArgs, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + }); + } + } + allEntries.sort((a, b) => a.timestamp - b.timestamp); + + // Build tool counts from activity entries + const toolCounts: Record = {}; + for (const e of allEntries) { + if (e.kind === 'tool' && e.tool) { + toolCounts[e.tool] = (toolCounts[e.tool] ?? 0) + 1; + } + } + + // Determine the primary model + const modelStr = models.size > 0 ? [...models].join(', ') : null; + + // Determine the project name from agents + const projectName = liveAgents.length > 0 + ? (liveAgents[0].projectName ?? 'Unknown Project') + : 'Unknown Project'; + + return { + source: liveAgents.length > 0 ? liveAgents[0].agentType : 'unknown', + projectName, + rootSessionId: liveAgents.length > 0 ? liveAgents[0].rootSessionId : 'unknown', + model: modelStr, + startedAt, + endedAt: null, // live session + durationMs: now - startedAt, + totalCost, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCacheReadTokens: totalCacheRead, + totalCacheCreationTokens: totalCacheCreation, + totalToolUses, + agents, + timeline: allEntries, + toolCounts, + }; +} diff --git a/packages/client/src/export/session-export-formatter.ts b/packages/client/src/export/session-export-formatter.ts new file mode 100644 index 0000000..1b58961 --- /dev/null +++ b/packages/client/src/export/session-export-formatter.ts @@ -0,0 +1,290 @@ +/** + * Session Export Formatter — Pure functions that produce Markdown and JSON + * from a normalized ExportableSession shape. No DOM, no side effects. + */ + +import { formatTokens, formatDuration } from '../utils/formatting.js'; + +// ─── Exportable Data Shapes ─────────────────────────────────── + +/** Normalized data shape fed into formatters (works for both live + recorded) */ +export interface ExportableSession { + source: string; + projectName: string; + projectPath?: string; + rootSessionId: string; + model: string | null; + label?: string | null; + startedAt: number; + endedAt: number | null; // null for live sessions + durationMs: number; + totalCost: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalToolUses: number; + agents: ExportableAgent[]; + timeline: ExportableTimelineEvent[]; + toolCounts: Record; +} + +export interface ExportableAgent { + agentId: string; + name: string; + role: string; + model: string | null; + cost: number; + totalInputTokens: number; + totalOutputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + toolUseCount: number; + durationMs: number; + status?: 'active' | 'idle' | 'done'; // live sessions only +} + +export interface ExportableTimelineEvent { + timestamp: number; + elapsedMs: number; + agentName: string; + kind: string; + zone?: string; + tool?: string; + toolArgs?: string; + inputTokens?: number; + outputTokens?: number; +} + +// ─── Markdown Formatter ─────────────────────────────────────── + +const MD_TIMELINE_LIMIT = 200; + +/** Generate markdown export string */ +export function formatSessionMarkdown(session: ExportableSession): string { + const lines: string[] = []; + const isLive = session.endedAt === null; + + lines.push('# AgentMove Session Export'); + lines.push(`> Generated ${new Date().toISOString()}`); + lines.push(''); + + // Overview + lines.push('## Overview'); + lines.push('| Metric | Value |'); + lines.push('|--------|-------|'); + lines.push(`| Source | ${sourceLabel(session.source)} |`); + if (session.model) lines.push(`| Model | ${session.model} |`); + if (session.label) lines.push(`| Label | ${session.label} |`); + lines.push(`| Duration | ${formatDuration(session.durationMs)} |`); + lines.push(`| Total Cost | $${session.totalCost.toFixed(4)} |`); + lines.push(`| Input Tokens | ${formatTokens(session.totalInputTokens)} |`); + lines.push(`| Output Tokens | ${formatTokens(session.totalOutputTokens)} |`); + lines.push(`| Cache Read | ${formatTokens(session.totalCacheReadTokens)} |`); + lines.push(`| Cache Created | ${formatTokens(session.totalCacheCreationTokens)} |`); + lines.push(`| Total Tool Uses | ${session.totalToolUses} |`); + if (isLive) lines.push(`| Status | Live |`); + lines.push(''); + + // Agents + if (session.agents.length > 0) { + lines.push('## Agents'); + lines.push('| Agent | Role | Model | Cost | Tokens | Tools | Duration |'); + lines.push('|-------|------|-------|------|--------|-------|----------|'); + const sorted = [...session.agents].sort((a, b) => b.cost - a.cost); + for (const a of sorted) { + const totalTok = a.totalInputTokens + a.totalOutputTokens; + lines.push(`| ${a.name} | ${a.role} | ${a.model ?? '-'} | $${a.cost.toFixed(4)} | ${formatTokens(totalTok)} | ${a.toolUseCount} | ${formatDuration(a.durationMs)} |`); + } + lines.push(''); + } else { + lines.push('## Agents'); + lines.push('No agents recorded.'); + lines.push(''); + } + + // Tool Usage + const toolEntries = Object.entries(session.toolCounts).sort((a, b) => b[1] - a[1]); + if (toolEntries.length > 0) { + lines.push('## Tool Usage'); + lines.push('| Tool | Count |'); + lines.push('|------|-------|'); + for (const [tool, count] of toolEntries) { + lines.push(`| ${tool} | ${count} |`); + } + lines.push(''); + } + + // Timeline + if (session.timeline.length > 0) { + const events = session.timeline.slice(-MD_TIMELINE_LIMIT); + const truncated = session.timeline.length > MD_TIMELINE_LIMIT; + lines.push(`## Timeline (${truncated ? `last ${MD_TIMELINE_LIMIT} of ${session.timeline.length}` : `${session.timeline.length}`} events)`); + lines.push('| Time | Agent | Event |'); + lines.push('|------|-------|-------|'); + for (const e of events) { + const time = `+${formatDuration(e.elapsedMs)}`; + const event = formatTimelineEventMd(e); + lines.push(`| ${time} | ${e.agentName} | ${event} |`); + } + lines.push(''); + } + + lines.push('---'); + lines.push('*Exported by AgentMove*'); + + return lines.join('\n'); +} + +function formatTimelineEventMd(e: ExportableTimelineEvent): string { + switch (e.kind) { + case 'tool': + return `Tool: ${e.tool ?? 'unknown'}${e.toolArgs ? ` ${truncateMd(e.toolArgs, 50)}` : ''}`; + case 'spawn': + return 'Spawned'; + case 'shutdown': + return 'Shut down'; + case 'idle': + return 'Idle'; + case 'zone-change': + return `Zone: ${e.zone ?? 'unknown'}`; + case 'tokens': + return `Tokens: +${formatTokens(e.inputTokens ?? 0)} in / +${formatTokens(e.outputTokens ?? 0)} out`; + default: + return e.kind; + } +} + +function truncateMd(s: string, max: number): string { + // Remove pipe chars to prevent table breakage + const clean = s.replace(/\|/g, '/').replace(/\n/g, ' '); + return clean.length > max ? clean.slice(0, max - 1) + '\u2026' : clean; +} + +function sourceLabel(source: string): string { + const labels: Record = { + claude: 'Claude Code', + opencode: 'OpenCode', + pi: 'pi', + codex: 'Codex CLI', + }; + return labels[source] ?? source; +} + +// ─── JSON Formatter ─────────────────────────────────────────── + +const JSON_TIMELINE_LIMIT = 500; + +export interface SessionExportJSON { + version: number; + exportedAt: string; + session: { + source: string; + projectName: string; + projectPath?: string; + rootSessionId: string; + model: string | null; + label?: string | null; + startedAt: number; + endedAt: number | null; + durationMs: number; + isLive: boolean; + }; + cost: { + total: number; + currency: string; + }; + tokens: { + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheCreation: number; + }; + agents: Array<{ + id: string; + name: string; + role: string; + model: string | null; + cost: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheCreation: number; + }; + toolUseCount: number; + durationMs: number; + status?: string; + }>; + toolUsage: Record; + timeline: Array<{ + timestamp: number; + elapsedMs: number; + agent: string; + kind: string; + tool?: string; + zone?: string; + inputTokens?: number; + outputTokens?: number; + }>; +} + +/** Generate structured JSON export object */ +export function formatSessionJSON(session: ExportableSession): SessionExportJSON { + const isLive = session.endedAt === null; + const timelineEvents = session.timeline.slice(-JSON_TIMELINE_LIMIT); + + return { + version: 1, + exportedAt: new Date().toISOString(), + session: { + source: session.source, + projectName: session.projectName, + ...(session.projectPath ? { projectPath: session.projectPath } : {}), + rootSessionId: session.rootSessionId, + model: session.model, + ...(session.label ? { label: session.label } : {}), + startedAt: session.startedAt, + endedAt: session.endedAt, + durationMs: session.durationMs, + isLive, + }, + cost: { + total: session.totalCost, + currency: 'USD', + }, + tokens: { + totalInput: session.totalInputTokens, + totalOutput: session.totalOutputTokens, + totalCacheRead: session.totalCacheReadTokens, + totalCacheCreation: session.totalCacheCreationTokens, + }, + agents: session.agents.map(a => ({ + id: a.agentId, + name: a.name, + role: a.role, + model: a.model, + cost: a.cost, + tokens: { + input: a.totalInputTokens, + output: a.totalOutputTokens, + cacheRead: a.cacheReadTokens, + cacheCreation: a.cacheCreationTokens, + }, + toolUseCount: a.toolUseCount, + durationMs: a.durationMs, + ...(a.status ? { status: a.status } : {}), + })), + toolUsage: { ...session.toolCounts }, + timeline: timelineEvents.map(e => ({ + timestamp: e.timestamp, + elapsedMs: e.elapsedMs, + agent: e.agentName, + kind: e.kind, + ...(e.tool ? { tool: e.tool } : {}), + ...(e.zone ? { zone: e.zone } : {}), + ...(e.inputTokens != null ? { inputTokens: e.inputTokens } : {}), + ...(e.outputTokens != null ? { outputTokens: e.outputTokens } : {}), + })), + }; +} diff --git a/packages/client/src/ui/session-detail-panel.ts b/packages/client/src/ui/session-detail-panel.ts index 00dea79..89d8c3a 100644 --- a/packages/client/src/ui/session-detail-panel.ts +++ b/packages/client/src/ui/session-detail-panel.ts @@ -3,6 +3,9 @@ import { getFunnyName, ZONE_MAP, computeAgentCost, FILE_WRITE_TOOLS, FILE_READ_T import { escapeHtml, escapeAttr, formatDuration, formatTokens, truncate, getSourceIcon, getSourceLabel, resolveAgentName } from '../utils/formatting.js'; import { fetchSession, fetchTimeline } from '../connection/session-api.js'; import type { StateStore } from '../connection/state-store.js'; +import { adaptRecordedSession, adaptLiveSession } from '../export/session-data-adapter.js'; +import { formatSessionMarkdown, formatSessionJSON } from '../export/session-export-formatter.js'; +import { copyToClipboard, downloadFile, generateExportFilename } from '../export/export-actions.js'; /** Minimal shape for timeline event rendering (shared between recorded + live) */ interface TimelineEntry { @@ -40,15 +43,27 @@ export class SessionDetailPanel { this.panelEl = document.createElement('div'); this.panelEl.id = 'session-detail-panel'; - this.panelEl.innerHTML = ` -
- -
-
- `; + // NOTE: innerHTML here uses only static trusted HTML template strings with + // no user data interpolation, so XSS is not a concern in this context. + this.panelEl.innerHTML = [ // eslint-disable-line -- static trusted template + '
', + ' ', + '
', + ' ', + '
', + ' ', + ' ', + '
', + ' ', + ' ', + '
', + '
', + '
', + '
', + ].join('\n'); const rightPanel = document.getElementById('right-panel'); if (rightPanel) { @@ -58,6 +73,25 @@ export class SessionDetailPanel { } this.panelEl.querySelector('#sd-back')!.addEventListener('click', () => this.close()); + + // Export dropdown toggle + const exportBtn = this.panelEl.querySelector('.sd-export-btn')!; + const exportMenu = this.panelEl.querySelector('.sd-export-menu')! as HTMLElement; + exportBtn.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu.classList.toggle('open'); + }); + // Close dropdown on outside click + document.addEventListener('click', () => exportMenu.classList.remove('open')); + // Wire export actions + exportMenu.querySelectorAll('button[data-action]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const action = (btn as HTMLElement).dataset.action; + exportMenu.classList.remove('open'); + this.handleExportAction(action!); + }); + }); } setNavigateToAgentHandler(handler: (agentId: string) => void): void { @@ -617,6 +651,69 @@ export class SessionDetailPanel { }); } + // ─── Export Helpers ──────────────────────────────────────── + + private getExportableSession() { + if (this.session && !this.isLive) { + // Recorded session — use recorded adapter + return adaptRecordedSession( + this.session, + this.timeline, + (agentId) => this.getAgentName(agentId), + ); + } + // Live session — use live adapter + const agents = this.liveSession + ? this.getSessionAgents(this.liveSession.rootSessionId) + : []; + return adaptLiveSession( + agents, + this.shutdownTotals, + this.liveActivityEntries, + (agentId) => { + const agent = this.store.getAgent(agentId); + return agent?.agentName || getFunnyName(agentId); + }, + ); + } + + private async handleExportAction(action: string): Promise { + const session = this.getExportableSession(); + switch (action) { + case 'copy-md': { + const md = formatSessionMarkdown(session); + const ok = await copyToClipboard(md); + if (ok) this.flashExportButton('Copied!'); + break; + } + case 'copy-json': { + const json = JSON.stringify(formatSessionJSON(session), null, 2); + const ok = await copyToClipboard(json); + if (ok) this.flashExportButton('Copied!'); + break; + } + case 'download-md': { + const md = formatSessionMarkdown(session); + downloadFile(md, generateExportFilename(session, 'md'), 'text/markdown'); + break; + } + case 'download-json': { + const json = JSON.stringify(formatSessionJSON(session), null, 2); + downloadFile(json, generateExportFilename(session, 'json'), 'application/json'); + break; + } + } + } + + private flashExportButton(text: string): void { + const btn = this.panelEl.querySelector('.sd-export-btn') as HTMLButtonElement; + if (btn) { + const original = btn.textContent; + btn.textContent = text; + setTimeout(() => { btn.textContent = original; }, 2000); + } + } + dispose(): void { this.cleanupLiveListeners(); this.panelEl.remove(); diff --git a/packages/client/src/ui/session-export.ts b/packages/client/src/ui/session-export.ts index d566291..fd49ec4 100644 --- a/packages/client/src/ui/session-export.ts +++ b/packages/client/src/ui/session-export.ts @@ -1,11 +1,16 @@ -import type { AgentState, ZoneId } from '@agent-move/shared'; -import { AGENT_PALETTES, ZONE_MAP, computeAgentCost } from '@agent-move/shared'; +import type { AgentState } from '@agent-move/shared'; +import { getFunnyName } from '@agent-move/shared'; import type { StateStore } from '../connection/state-store.js'; -import { formatTokens, formatDuration } from '../utils/formatting.js'; +import { adaptLiveSession } from '../export/session-data-adapter.js'; +import { formatSessionMarkdown, formatSessionJSON } from '../export/session-export-formatter.js'; +import { copyToClipboard, downloadFile, generateExportFilename } from '../export/export-actions.js'; /** - * Session Summary / Export — generates a markdown report of the current session - * and copies it to clipboard or downloads as a file. + * Session Summary / Export — generates markdown + JSON reports of the current + * live session, copies to clipboard or downloads as a file. + * + * NOTE: This uses innerHTML with static, trusted HTML templates only (no user + * data is interpolated in the template). All user data is set via textContent. */ export class SessionExport { @@ -19,6 +24,7 @@ export class SessionExport { this.el = document.createElement('div'); this.el.id = 'session-export'; + // Static trusted template — no user data interpolated this.el.innerHTML = `
@@ -30,8 +36,10 @@ export class SessionExport {

         
`; @@ -39,8 +47,10 @@ export class SessionExport { this.el.querySelector('.se-backdrop')!.addEventListener('click', () => this.close()); this.el.querySelector('.se-close')!.addEventListener('click', () => this.close()); - this.el.querySelector('.se-copy-btn')!.addEventListener('click', () => this.copyToClipboard()); - this.el.querySelector('.se-download-btn')!.addEventListener('click', () => this.download()); + this.el.querySelector('.se-copy-md-btn')!.addEventListener('click', () => this.copyMarkdown()); + this.el.querySelector('.se-copy-json-btn')!.addEventListener('click', () => this.copyJSON()); + this.el.querySelector('.se-download-md-btn')!.addEventListener('click', () => this.downloadMarkdown()); + this.el.querySelector('.se-download-json-btn')!.addEventListener('click', () => this.downloadJSON()); } setCustomizationLookup(fn: (agent: AgentState) => { displayName: string; colorIndex: number }): void { @@ -63,128 +73,59 @@ export class SessionExport { this.el.classList.remove('open'); } - private generateReport(): string { + private getExportableSession() { const agents = Array.from(this.store.getAgents().values()); - const now = Date.now(); - - // Session metadata - const earliest = agents.length > 0 - ? Math.min(...agents.map(a => a.spawnedAt)) - : now; - const sessionDuration = now - earliest; - - // Cost calculation - let totalCost = 0; - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - - const agentStats: { name: string; cost: number; tokens: number; duration: number; role: string; zone: string; status: string }[] = []; - - for (const a of agents) { - const cost = computeAgentCost(a); - totalCost += cost; - totalInput += a.totalInputTokens; - totalOutput += a.totalOutputTokens; - totalCacheRead += a.cacheReadTokens; - - const zone = ZONE_MAP.get(a.currentZone); - agentStats.push({ - name: this._customizationLookup?.(a)?.displayName || a.agentName || a.projectName || a.sessionId.slice(0, 10), - cost, - tokens: a.totalInputTokens + a.totalOutputTokens, - duration: now - a.spawnedAt, - role: a.role, - zone: zone?.label ?? a.currentZone, - status: a.isDone ? 'Done' : a.isIdle ? 'Idle' : 'Active', - }); - } - - agentStats.sort((a, b) => b.cost - a.cost); - - // Build markdown - const lines: string[] = []; - lines.push(`# AgentMove Session Summary`); - lines.push(`> Generated ${new Date().toISOString()}`); - lines.push(''); - lines.push(`## Overview`); - lines.push(`| Metric | Value |`); - lines.push(`|--------|-------|`); - lines.push(`| Duration | ${formatDuration(sessionDuration)} |`); - lines.push(`| Total Agents | ${agents.length} |`); - lines.push(`| Active | ${agents.filter(a => !a.isIdle && !a.isDone).length} |`); - lines.push(`| Idle | ${agents.filter(a => a.isIdle && !a.isDone).length} |`); - lines.push(`| Done | ${agents.filter(a => a.isDone).length} |`); - lines.push(`| Total Cost | $${totalCost.toFixed(4)} |`); - lines.push(`| Input Tokens | ${formatTokens(totalInput)} |`); - lines.push(`| Output Tokens | ${formatTokens(totalOutput)} |`); - lines.push(`| Cache Reads | ${formatTokens(totalCacheRead)} |`); - lines.push(''); - - if (agentStats.length > 0) { - lines.push(`## Agents`); - lines.push(`| Agent | Role | Status | Zone | Cost | Tokens | Duration |`); - lines.push(`|-------|------|--------|------|------|--------|----------|`); - for (const a of agentStats) { - lines.push(`| ${a.name} | ${a.role} | ${a.status} | ${a.zone} | $${a.cost.toFixed(4)} | ${formatTokens(a.tokens)} | ${formatDuration(a.duration)} |`); + const resolveName = (agentId: string): string => { + const agent = this.store.getAgent(agentId); + if (agent && this._customizationLookup) { + return this._customizationLookup(agent).displayName; } - lines.push(''); - } + return agent?.agentName || getFunnyName(agentId); + }; + // No shutdown totals or activity entries available in the modal context — + // the modal shows a snapshot of currently-live agents only. + return adaptLiveSession(agents, { cost: 0, input: 0, output: 0, tools: 0 }, new Map(), resolveName); + } - // Zone usage - const zoneCounts = new Map(); - for (const a of agents) { - const label = ZONE_MAP.get(a.currentZone)?.label ?? a.currentZone; - zoneCounts.set(label, (zoneCounts.get(label) ?? 0) + 1); - } - if (zoneCounts.size > 0) { - lines.push(`## Zone Distribution`); - lines.push(`| Zone | Agents |`); - lines.push(`|------|--------|`); - for (const [zone, count] of Array.from(zoneCounts.entries()).sort((a, b) => b[1] - a[1])) { - lines.push(`| ${zone} | ${count} |`); - } - lines.push(''); - } + private render(): void { + const content = this.el.querySelector('.se-content')!; + const session = this.getExportableSession(); + // textContent is safe — no HTML injection possible + content.textContent = formatSessionMarkdown(session); + } - lines.push('---'); - lines.push('*Generated by AgentMove*'); + private async copyMarkdown(): Promise { + const session = this.getExportableSession(); + const md = formatSessionMarkdown(session); + const ok = await copyToClipboard(md); + if (ok) this.flashButton('.se-copy-md-btn', 'Copied!', 'Copy MD'); + } - return lines.join('\n'); + private async copyJSON(): Promise { + const session = this.getExportableSession(); + const json = JSON.stringify(formatSessionJSON(session), null, 2); + const ok = await copyToClipboard(json); + if (ok) this.flashButton('.se-copy-json-btn', 'Copied!', 'Copy JSON'); } - private render(): void { - const content = this.el.querySelector('.se-content')!; - content.textContent = this.generateReport(); + private downloadMarkdown(): void { + const session = this.getExportableSession(); + const md = formatSessionMarkdown(session); + downloadFile(md, generateExportFilename(session, 'md'), 'text/markdown'); } - private async copyToClipboard(): Promise { - const report = this.generateReport(); - try { - await navigator.clipboard.writeText(report); - const btn = this.el.querySelector('.se-copy-btn') as HTMLButtonElement; - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy to Clipboard'; }, 2000); - } catch { - // Fallback - const textarea = document.createElement('textarea'); - textarea.value = report; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - textarea.remove(); - } + private downloadJSON(): void { + const session = this.getExportableSession(); + const json = JSON.stringify(formatSessionJSON(session), null, 2); + downloadFile(json, generateExportFilename(session, 'json'), 'application/json'); } - private download(): void { - const report = this.generateReport(); - const blob = new Blob([report], { type: 'text/markdown' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `agent-move-session-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.md`; - a.click(); - URL.revokeObjectURL(url); + private flashButton(selector: string, flashText: string, originalText: string): void { + const btn = this.el.querySelector(selector) as HTMLButtonElement; + if (btn) { + btn.textContent = flashText; + setTimeout(() => { btn.textContent = originalText; }, 2000); + } } dispose(): void { diff --git a/packages/client/styles/modals.css b/packages/client/styles/modals.css index 1427ee0..0492dbc 100644 --- a/packages/client/styles/modals.css +++ b/packages/client/styles/modals.css @@ -76,8 +76,10 @@ .se-footer { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border-subtle); } .se-footer button { height: var(--btn-height-md); border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-3); color: var(--text-secondary); font-family: var(--font-ui); font-size: 12px; font-weight: 500; cursor: pointer; padding: 0 16px; transition: background 0.15s, color 0.15s, border-color 0.15s; } .se-footer button:hover { background: var(--surface-bright); color: var(--text-primary); } -.se-footer .se-copy-btn { background: var(--accent-dim); color: var(--accent); border-color: rgba(233, 69, 96, 0.3); } -.se-footer .se-copy-btn:hover { background: rgba(233, 69, 96, 0.25); } +.se-footer .se-copy-md-btn, +.se-footer .se-copy-json-btn { background: var(--accent-dim); color: var(--accent); border-color: rgba(233, 69, 96, 0.3); } +.se-footer .se-copy-md-btn:hover, +.se-footer .se-copy-json-btn:hover { background: rgba(233, 69, 96, 0.25); } /* ═══════════════════════════════════════════ AGENT CUSTOMIZER diff --git a/packages/client/styles/sessions.css b/packages/client/styles/sessions.css index d23b2a2..a72c6ac 100644 --- a/packages/client/styles/sessions.css +++ b/packages/client/styles/sessions.css @@ -373,6 +373,75 @@ .sd-header { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Export dropdown */ +.sd-export-dropdown { + position: relative; +} + +.sd-export-btn { + height: var(--btn-height-sm); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text-dim); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + padding: 0 10px; + font-family: var(--font-ui); +} + +.sd-export-btn:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.sd-export-menu { + display: none; + position: absolute; + right: 0; + top: 100%; + margin-top: 4px; + background: var(--surface-2); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: 4px 0; + min-width: 160px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.sd-export-menu.open { + display: block; +} + +.sd-export-menu button { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: none; + color: var(--text-secondary); + font-size: var(--text-sm); + font-family: var(--font-ui); + text-align: left; + cursor: pointer; +} + +.sd-export-menu button:hover { + background: var(--surface-hover); + color: var(--text-primary); +} + +.sd-export-menu hr { + border: none; + border-top: 1px solid var(--border-subtle); + margin: 4px 0; } #sd-back {