mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge PR #136: Away digest feature (COD-41)
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
import type { LifecycleEntry, RunSummary, RunSummaryEvent, TokenUsageEntry } from '../types.js';
|
||||
|
||||
export type AwayDigestRangeName = 'since-last-visit' | '1h' | 'today' | '24h' | 'custom';
|
||||
export type AwayDigestCategory = 'needs_attention' | 'completed' | 'still_running' | 'idle' | 'informational';
|
||||
export type AwayDigestSectionName = 'needsAttention' | 'completed' | 'stillRunning' | 'idle' | 'informational';
|
||||
export type AwayDigestSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
export type AwayDigestSource = 'lifecycle' | 'run_summary' | 'status' | 'token_stats' | 'subagent';
|
||||
export type AwayDigestTokenWindowPrecision = 'day' | 'none';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const VALID_RANGES = new Set<AwayDigestRangeName>(['since-last-visit', '1h', 'today', '24h', 'custom']);
|
||||
|
||||
export interface AwayDigestRange {
|
||||
range: AwayDigestRangeName;
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestRangeInput {
|
||||
range?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
lastViewed?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSession {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSubagent {
|
||||
id?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
lastUpdated?: number;
|
||||
updatedAt?: number;
|
||||
completedAt?: number;
|
||||
modifiedAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestItem {
|
||||
id: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
timestamp: number;
|
||||
category: AwayDigestCategory;
|
||||
severity: AwayDigestSeverity;
|
||||
title: string;
|
||||
detail?: string;
|
||||
source: AwayDigestSource;
|
||||
link?: {
|
||||
type: 'session' | 'run_summary' | 'lifecycle' | 'notification';
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AwayDigestTotals {
|
||||
sessionsCreated: number;
|
||||
sessionsExited: number;
|
||||
activeSessions: number;
|
||||
needsAttention: number;
|
||||
completed: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
estimatedCost?: number;
|
||||
tokenWindowPrecision: AwayDigestTokenWindowPrecision;
|
||||
}
|
||||
|
||||
export interface AwayDigestResponse {
|
||||
range: AwayDigestRange;
|
||||
generatedAt: number;
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true;
|
||||
tokenStatsPersisted: true;
|
||||
runSummariesLiveOnly: true;
|
||||
subagentsLiveOnly: true;
|
||||
};
|
||||
totals: AwayDigestTotals;
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>;
|
||||
}
|
||||
|
||||
export interface AwayDigestInput {
|
||||
range: AwayDigestRange;
|
||||
lifecycleEntries: LifecycleEntry[];
|
||||
runSummaries: RunSummary[];
|
||||
sessions: AwayDigestSession[];
|
||||
dailyTokenStats: TokenUsageEntry[];
|
||||
subagents: AwayDigestSubagent[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export function resolveAwayDigestRange(input: AwayDigestRangeInput): AwayDigestRange {
|
||||
const now = input.now ?? Date.now();
|
||||
const range = (input.range ?? 'since-last-visit') as AwayDigestRangeName;
|
||||
if (!VALID_RANGES.has(range)) {
|
||||
throw new Error(`Invalid away digest range: ${input.range}`);
|
||||
}
|
||||
|
||||
let since: number;
|
||||
const until = finiteOrDefault(input.until, now);
|
||||
|
||||
switch (range) {
|
||||
case 'since-last-visit':
|
||||
since = finiteOrDefault(input.lastViewed, now - DAY_MS);
|
||||
break;
|
||||
case '1h':
|
||||
since = now - HOUR_MS;
|
||||
break;
|
||||
case 'today': {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
since = start.getTime();
|
||||
break;
|
||||
}
|
||||
case '24h':
|
||||
since = now - DAY_MS;
|
||||
break;
|
||||
case 'custom':
|
||||
if (!Number.isFinite(input.since)) {
|
||||
throw new Error('Custom away digest range requires a finite since timestamp');
|
||||
}
|
||||
since = input.since as number;
|
||||
break;
|
||||
}
|
||||
|
||||
if (until < since) {
|
||||
throw new Error('Away digest until timestamp must be greater than or equal to since');
|
||||
}
|
||||
|
||||
return { range, since, until };
|
||||
}
|
||||
|
||||
export function buildAwayDigest(input: AwayDigestInput): AwayDigestResponse {
|
||||
const now = input.now ?? Date.now();
|
||||
const sections: Record<AwayDigestSectionName, AwayDigestItem[]> = {
|
||||
needsAttention: [],
|
||||
completed: [],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
};
|
||||
|
||||
const sessionsById = new Map(input.sessions.map((session) => [session.id, session]));
|
||||
const lifecycleEntries = input.lifecycleEntries.filter((entry) => isInRange(entry.ts, input.range));
|
||||
|
||||
for (const entry of lifecycleEntries) {
|
||||
addItem(sections, lifecycleEntryToItem(entry));
|
||||
}
|
||||
|
||||
for (const summary of input.runSummaries) {
|
||||
for (const event of summary.events) {
|
||||
if (!isInRange(event.timestamp, input.range)) continue;
|
||||
addItem(sections, runSummaryEventToItem(summary, event));
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of input.sessions) {
|
||||
const item = sessionToItem(session, now);
|
||||
addItem(sections, item);
|
||||
}
|
||||
|
||||
for (const subagent of input.subagents) {
|
||||
const timestamp = subagentTimestamp(subagent, now);
|
||||
if (!isInRange(timestamp, input.range) || subagent.status !== 'completed') continue;
|
||||
addItem(sections, subagentToItem(subagent, sessionsById, timestamp));
|
||||
}
|
||||
|
||||
const tokenTotals = aggregateTokenStats(input.dailyTokenStats, input.range);
|
||||
const totals = calculateTotals(sections, lifecycleEntries, input.sessions, tokenTotals);
|
||||
|
||||
return {
|
||||
range: input.range,
|
||||
generatedAt: now,
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
},
|
||||
totals,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
function finiteOrDefault(value: number | undefined, fallback: number): number {
|
||||
return Number.isFinite(value) ? (value as number) : fallback;
|
||||
}
|
||||
|
||||
function isInRange(timestamp: number, range: AwayDigestRange): boolean {
|
||||
return timestamp >= range.since && timestamp <= range.until;
|
||||
}
|
||||
|
||||
function addItem(sections: Record<AwayDigestSectionName, AwayDigestItem[]>, item: AwayDigestItem): void {
|
||||
sections[sectionNameForCategory(item.category)].push(item);
|
||||
}
|
||||
|
||||
function sectionNameForCategory(category: AwayDigestCategory): AwayDigestSectionName {
|
||||
switch (category) {
|
||||
case 'needs_attention':
|
||||
return 'needsAttention';
|
||||
case 'still_running':
|
||||
return 'stillRunning';
|
||||
case 'completed':
|
||||
case 'idle':
|
||||
case 'informational':
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleEntryToItem(entry: LifecycleEntry): AwayDigestItem {
|
||||
const needsAttention = entry.event === 'mux_died' || (entry.event === 'exit' && (entry.exitCode ?? 0) !== 0);
|
||||
return {
|
||||
id: `lifecycle-${entry.ts}-${entry.event}-${entry.sessionId}`,
|
||||
sessionId: entry.sessionId,
|
||||
sessionName: entry.name,
|
||||
timestamp: entry.ts,
|
||||
category: needsAttention ? 'needs_attention' : 'informational',
|
||||
severity: needsAttention ? 'error' : entry.event === 'exit' ? 'info' : 'info',
|
||||
title: lifecycleTitle(entry),
|
||||
detail: lifecycleDetail(entry),
|
||||
source: 'lifecycle',
|
||||
link: { type: 'lifecycle', sessionId: entry.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleTitle(entry: LifecycleEntry): string {
|
||||
if (entry.event === 'exit') {
|
||||
return (entry.exitCode ?? 0) === 0 ? 'Session exited' : 'Session exited with error';
|
||||
}
|
||||
if (entry.event === 'mux_died') return 'Tmux session died';
|
||||
return `Session ${entry.event.replaceAll('_', ' ')}`;
|
||||
}
|
||||
|
||||
function lifecycleDetail(entry: LifecycleEntry): string | undefined {
|
||||
if (entry.reason) return entry.reason;
|
||||
if (entry.event === 'exit' && entry.exitCode !== undefined && entry.exitCode !== null) {
|
||||
return `Exit code ${entry.exitCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runSummaryEventToItem(summary: RunSummary, event: RunSummaryEvent): AwayDigestItem {
|
||||
const category = runSummaryCategory(event);
|
||||
return {
|
||||
id: `run-summary-${summary.sessionId}-${event.id}`,
|
||||
sessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
timestamp: event.timestamp,
|
||||
category,
|
||||
severity: runSummarySeverity(event, category),
|
||||
title: event.title,
|
||||
detail: event.details,
|
||||
source: 'run_summary',
|
||||
link: { type: 'run_summary', sessionId: summary.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function runSummaryCategory(event: RunSummaryEvent): AwayDigestCategory {
|
||||
if (event.type === 'ralph_completion') return 'completed';
|
||||
if (event.severity === 'error' || event.severity === 'warning' || event.type === 'state_stuck') {
|
||||
return 'needs_attention';
|
||||
}
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
function runSummarySeverity(event: RunSummaryEvent, category: AwayDigestCategory): AwayDigestSeverity {
|
||||
if (category === 'completed') return 'success';
|
||||
return event.severity;
|
||||
}
|
||||
|
||||
function sessionToItem(session: AwayDigestSession, now: number): AwayDigestItem {
|
||||
const isIdle = session.status === 'idle';
|
||||
return {
|
||||
id: `status-${session.id}`,
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
timestamp: now,
|
||||
category: isIdle ? 'idle' : 'still_running',
|
||||
severity: isIdle ? 'info' : 'success',
|
||||
title: isIdle ? 'Session idle' : 'Session still running',
|
||||
detail: session.status ? `Status: ${session.status}` : undefined,
|
||||
source: 'status',
|
||||
link: { type: 'session', sessionId: session.id },
|
||||
};
|
||||
}
|
||||
|
||||
function subagentToItem(
|
||||
subagent: AwayDigestSubagent,
|
||||
sessionsById: Map<string, AwayDigestSession>,
|
||||
timestamp: number
|
||||
): AwayDigestItem {
|
||||
const session = subagent.sessionId ? sessionsById.get(subagent.sessionId) : undefined;
|
||||
const agentId = subagent.id ?? subagent.agentId ?? 'unknown';
|
||||
return {
|
||||
id: `subagent-${agentId}`,
|
||||
sessionId: subagent.sessionId,
|
||||
sessionName: session?.name,
|
||||
timestamp,
|
||||
category: 'informational',
|
||||
severity: 'success',
|
||||
title: 'Subagent completed',
|
||||
detail: subagent.description,
|
||||
source: 'subagent',
|
||||
link: subagent.sessionId ? { type: 'session', sessionId: subagent.sessionId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function subagentTimestamp(subagent: AwayDigestSubagent, fallback: number): number {
|
||||
return (
|
||||
subagent.completedAt ??
|
||||
subagent.lastUpdated ??
|
||||
subagent.updatedAt ??
|
||||
subagent.modifiedAt ??
|
||||
subagent.lastActivityAt ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateTokenStats(
|
||||
dailyTokenStats: TokenUsageEntry[],
|
||||
range: AwayDigestRange
|
||||
): { inputTokens: number; outputTokens: number; estimatedCost: number; precision: AwayDigestTokenWindowPrecision } {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let estimatedCost = 0;
|
||||
|
||||
for (const day of dailyTokenStats) {
|
||||
if (!dayOverlapsRange(day.date, range)) continue;
|
||||
inputTokens += day.inputTokens;
|
||||
outputTokens += day.outputTokens;
|
||||
estimatedCost += day.estimatedCost;
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
estimatedCost,
|
||||
precision: inputTokens > 0 || outputTokens > 0 || estimatedCost > 0 ? 'day' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
function dayOverlapsRange(date: string, range: AwayDigestRange): boolean {
|
||||
const dayStart = new Date(`${date}T00:00:00`).getTime();
|
||||
const dayEnd = dayStart + DAY_MS - 1;
|
||||
return dayStart <= range.until && dayEnd >= range.since;
|
||||
}
|
||||
|
||||
function calculateTotals(
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>,
|
||||
lifecycleEntries: LifecycleEntry[],
|
||||
sessions: AwayDigestSession[],
|
||||
tokenTotals: ReturnType<typeof aggregateTokenStats>
|
||||
): AwayDigestTotals {
|
||||
const allItems = Object.values(sections).flat();
|
||||
return {
|
||||
sessionsCreated: lifecycleEntries.filter((entry) => entry.event === 'created').length,
|
||||
sessionsExited: lifecycleEntries.filter((entry) => entry.event === 'exit').length,
|
||||
activeSessions: sessions.length,
|
||||
needsAttention: sections.needsAttention.length,
|
||||
completed: sections.completed.length,
|
||||
errors: allItems.filter((item) => item.severity === 'error').length,
|
||||
warnings: allItems.filter((item) => item.severity === 'warning').length,
|
||||
inputTokens: tokenTotals.inputTokens,
|
||||
outputTokens: tokenTotals.outputTokens,
|
||||
estimatedCost: tokenTotals.estimatedCost,
|
||||
tokenWindowPrecision: tokenTotals.precision,
|
||||
};
|
||||
}
|
||||
@@ -118,6 +118,7 @@
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><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"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<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-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><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="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" 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>
|
||||
@@ -1863,6 +1864,44 @@
|
||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||
</div>
|
||||
|
||||
<!-- Away Digest Modal -->
|
||||
<div class="modal" id="awayDigestModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAwayDigest()"></div>
|
||||
<div class="modal-content away-digest-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Away Digest</h3>
|
||||
<div class="modal-header-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.loadAwayDigest()" title="Refresh away digest">↻ Refresh</button>
|
||||
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="away-digest-ranges" role="group" aria-label="Away digest range">
|
||||
<button class="filter-btn active" data-away-range="since-last-visit" onclick="app.setAwayDigestRange('since-last-visit')">Since last visit</button>
|
||||
<button class="filter-btn" data-away-range="1h" onclick="app.setAwayDigestRange('1h')">Last hour</button>
|
||||
<button class="filter-btn" data-away-range="today" onclick="app.setAwayDigestRange('today')">Today</button>
|
||||
<button class="filter-btn" data-away-range="24h" onclick="app.setAwayDigestRange('24h')">24h</button>
|
||||
<button class="filter-btn" data-away-range="custom" onclick="app.setAwayDigestRange('custom')">Custom</button>
|
||||
</div>
|
||||
<div class="away-digest-custom-range" id="awayDigestCustomRange">
|
||||
<label>
|
||||
Since
|
||||
<input type="datetime-local" id="awayDigestCustomSince">
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" id="awayDigestCustomUntil">
|
||||
</label>
|
||||
</div>
|
||||
<div class="away-digest-summary" id="awayDigestSummary"></div>
|
||||
<div class="away-digest-freshness" id="awayDigestFreshness"></div>
|
||||
<div class="away-digest-sections" id="awayDigestSections">
|
||||
<p class="empty-message">Open the digest to load recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
|
||||
@@ -434,11 +434,14 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
||||
/* Hide header settings gear, lifecycle log, and away digest on mobile - settings
|
||||
moved to toolbar; away digest is a secondary informational control that doesn't
|
||||
belong on the cramped phone header.
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log {
|
||||
.btn-icon-header.btn-lifecycle-log,
|
||||
.btn-icon-header.btn-away-digest {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1163,6 +1166,44 @@ html.mobile-init .file-browser-panel {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.away-digest-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.away-digest-ranges .filter-btn {
|
||||
min-height: 38px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-custom-range,
|
||||
.away-digest-custom-range.active {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.away-digest-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-action {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.modal-footer,
|
||||
.form-actions {
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
|
||||
*/
|
||||
|
||||
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
['stillRunning', 'Still Running'],
|
||||
['idle', 'Idle'],
|
||||
['informational', 'Informational'],
|
||||
];
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||
const activity = this.subagentActivity.get(agentId) || [];
|
||||
@@ -242,6 +251,271 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Away Digest Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openAwayDigest(range = 'since-last-visit') {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this._awayDigestSinceLastVisitGeneratedAt = undefined;
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
this.updateAwayDigestRangeControls();
|
||||
await this.loadAwayDigest();
|
||||
},
|
||||
|
||||
closeAwayDigest() {
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
|
||||
if (Number.isFinite(generatedAt)) {
|
||||
try {
|
||||
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
|
||||
} catch (err) {
|
||||
console.warn('Failed to save away digest last-viewed marker:', err);
|
||||
}
|
||||
}
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
setAwayDigestRange(range) {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this.updateAwayDigestRangeControls();
|
||||
this.loadAwayDigest();
|
||||
},
|
||||
|
||||
updateAwayDigestRangeControls() {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
document.querySelectorAll('[data-away-range]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.awayRange === range);
|
||||
});
|
||||
const customRange = document.getElementById('awayDigestCustomRange');
|
||||
if (customRange) customRange.classList.toggle('active', range === 'custom');
|
||||
if (range === 'custom') this.ensureAwayDigestCustomDefaults();
|
||||
},
|
||||
|
||||
ensureAwayDigestCustomDefaults() {
|
||||
const sinceInput = document.getElementById('awayDigestCustomSince');
|
||||
const untilInput = document.getElementById('awayDigestCustomUntil');
|
||||
if (!sinceInput || !untilInput) return;
|
||||
|
||||
const now = new Date();
|
||||
if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now);
|
||||
if (!sinceInput.value) {
|
||||
const since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
sinceInput.value = this.formatAwayDigestDateTimeLocal(since);
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestDateTimeLocal(date) {
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
},
|
||||
|
||||
async loadAwayDigest() {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-loading">Loading digest...</div>';
|
||||
if (freshnessEl) freshnessEl.textContent = '';
|
||||
if (sectionsEl) sectionsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
const params = new URLSearchParams({ range });
|
||||
|
||||
if (range === 'since-last-visit') {
|
||||
const lastViewed = this.readAwayDigestLastViewed();
|
||||
if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed));
|
||||
}
|
||||
|
||||
if (range === 'custom') {
|
||||
this.ensureAwayDigestCustomDefaults();
|
||||
const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince');
|
||||
const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil');
|
||||
if (!Number.isFinite(since)) {
|
||||
throw new Error('Choose a custom start time');
|
||||
}
|
||||
params.set('since', String(since));
|
||||
if (Number.isFinite(until)) params.set('until', String(until));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/away-digest?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to load away digest');
|
||||
}
|
||||
|
||||
this._awayDigestLoadedSuccessfully = true;
|
||||
this._awayDigestGeneratedAt = data.digest.generatedAt;
|
||||
if (range === 'since-last-visit') {
|
||||
this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt;
|
||||
}
|
||||
this.renderAwayDigest(data.digest);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load away digest';
|
||||
console.error('Failed to fetch away digest:', err);
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-load-error">Failed to load away digest</div>';
|
||||
if (sectionsEl) {
|
||||
sectionsEl.innerHTML = `<div class="empty-message">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestLastViewed() {
|
||||
try {
|
||||
const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestDateTimeInput(id) {
|
||||
const input = document.getElementById(id);
|
||||
if (!input || !input.value) return undefined;
|
||||
const parsed = Date.parse(input.value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
},
|
||||
|
||||
renderAwayDigest(digest) {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!summaryEl || !freshnessEl || !sectionsEl) return;
|
||||
|
||||
const inputTokens = digest.totals.inputTokens || 0;
|
||||
const outputTokens = digest.totals.outputTokens || 0;
|
||||
const estimatedCost = digest.totals.estimatedCost || 0;
|
||||
summaryEl.innerHTML = `
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Needs Attention</span>
|
||||
<span class="away-digest-card-value">${digest.totals.needsAttention}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Completed</span>
|
||||
<span class="away-digest-card-value">${digest.totals.completed}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Active Sessions</span>
|
||||
<span class="away-digest-card-value">${digest.totals.activeSessions}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Tokens</span>
|
||||
<span class="away-digest-card-value">${this.formatTokens(inputTokens + outputTokens)}</span>
|
||||
<span class="away-digest-card-cost">~$${estimatedCost.toFixed(2)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const freshnessNotes = [];
|
||||
if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) {
|
||||
freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.');
|
||||
}
|
||||
if (digest.totals.tokenWindowPrecision === 'day') {
|
||||
freshnessNotes.push('Token totals are aggregated at day precision.');
|
||||
}
|
||||
freshnessEl.textContent = freshnessNotes.join(' ');
|
||||
|
||||
sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS
|
||||
.map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || []))
|
||||
.join('');
|
||||
this.attachAwayDigestActions();
|
||||
},
|
||||
|
||||
renderAwayDigestSection(title, items) {
|
||||
const count = items.length;
|
||||
const body = count
|
||||
? items.map(item => this.renderAwayDigestItem(item)).join('')
|
||||
: '<div class="away-digest-empty">No items</div>';
|
||||
return `
|
||||
<section class="away-digest-section">
|
||||
<div class="away-digest-section-title">
|
||||
<h4>${escapeHtml(title)}</h4>
|
||||
<span>${count}</span>
|
||||
</div>
|
||||
${body}
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
renderAwayDigestItem(item) {
|
||||
const sourceLabel = this.formatAwayDigestSource(item.source);
|
||||
const sessionLabel = item.sessionName || item.sessionId || '';
|
||||
const detail = item.detail ? `<div class="away-digest-item-detail">${escapeHtml(item.detail)}</div>` : '';
|
||||
const action = item.link ? `
|
||||
<button class="away-digest-action"
|
||||
data-away-link-type="${escapeHtml(item.link.type)}"
|
||||
data-away-session-id="${escapeHtml(item.link.sessionId || '')}">
|
||||
Open
|
||||
</button>
|
||||
` : '';
|
||||
return `
|
||||
<article class="away-digest-item away-digest-${escapeHtml(item.severity)}">
|
||||
<div class="away-digest-item-main">
|
||||
<div class="away-digest-item-meta">
|
||||
<span>${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))}</span>
|
||||
<span>${escapeHtml(sourceLabel)}</span>
|
||||
${sessionLabel ? `<span>${escapeHtml(sessionLabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="away-digest-item-title">${escapeHtml(item.title)}</div>
|
||||
${detail}
|
||||
</div>
|
||||
${action}
|
||||
</article>
|
||||
`;
|
||||
},
|
||||
|
||||
attachAwayDigestActions() {
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!sectionsEl) return;
|
||||
sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async openAwayDigestItem(type, sessionId) {
|
||||
if (type === 'session' && sessionId) {
|
||||
await this.selectSession(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'run_summary' && sessionId) {
|
||||
await this.openRunSummary(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'lifecycle') {
|
||||
this.openLifecycleLog();
|
||||
this.closeAwayDigest();
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
formatAwayDigestSource(source) {
|
||||
const labels = {
|
||||
lifecycle: 'Lifecycle',
|
||||
run_summary: 'Run Summary',
|
||||
status: 'Status',
|
||||
token_stats: 'Token Stats',
|
||||
subagent: 'Subagent',
|
||||
};
|
||||
return labels[source] || source;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Token Statistics Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -5108,6 +5108,238 @@ kbd {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Away Digest Modal */
|
||||
.away-digest-modal {
|
||||
max-width: 860px;
|
||||
width: min(92vw, 860px);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.away-digest-modal .modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.away-digest-custom-range {
|
||||
display: none;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.away-digest-custom-range.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.away-digest-custom-range label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.away-digest-custom-range input {
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.away-digest-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.away-digest-card,
|
||||
.away-digest-loading,
|
||||
.away-digest-load-error {
|
||||
min-height: 74px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.away-digest-loading,
|
||||
.away-digest-load-error {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.away-digest-load-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.away-digest-card-label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.away-digest-card-value {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.away-digest-card-cost {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--accent-hover);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.away-digest-freshness {
|
||||
min-height: 18px;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.away-digest-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.away-digest-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.away-digest-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.away-digest-section-title h4 {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.away-digest-section-title span {
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.away-digest-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-left: 3px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.away-digest-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.away-digest-success {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
|
||||
.away-digest-warning {
|
||||
border-left-color: var(--yellow);
|
||||
}
|
||||
|
||||
.away-digest-error {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
|
||||
.away-digest-info {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.away-digest-item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.away-digest-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.55rem;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.away-digest-item-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.away-digest-item-detail {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.away-digest-action {
|
||||
min-height: 32px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
color: var(--accent-hover);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.away-digest-action:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(96, 165, 250, 0.45);
|
||||
}
|
||||
|
||||
.away-digest-empty {
|
||||
padding: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Token Stats Modal */
|
||||
.token-stats-modal {
|
||||
max-width: 560px;
|
||||
|
||||
@@ -29,6 +29,12 @@ import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
buildAwayDigest,
|
||||
resolveAwayDigestRange,
|
||||
type AwayDigestSession,
|
||||
type AwayDigestSubagent,
|
||||
} from '../away-digest.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
formatUptime,
|
||||
@@ -52,6 +58,12 @@ const SCREENSHOTS_DIR = dataPath('screenshots');
|
||||
/** Cached CPU count — doesn't change at runtime */
|
||||
const CPU_COUNT = cpus().length;
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||
if (value === undefined || value.trim() === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
/** Get system CPU and memory usage */
|
||||
function getSystemStats(): {
|
||||
cpu: number;
|
||||
@@ -415,6 +427,56 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/away-digest', async (req, reply) => {
|
||||
const query = req.query as {
|
||||
range?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
lastViewed?: string;
|
||||
};
|
||||
|
||||
let range;
|
||||
try {
|
||||
range = resolveAwayDigestRange({
|
||||
range: query.range,
|
||||
since: parseOptionalNumber(query.since),
|
||||
until: parseOptionalNumber(query.until),
|
||||
lastViewed: parseOptionalNumber(query.lastViewed),
|
||||
});
|
||||
} catch (err) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
|
||||
}
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
const lifecycleEntries = await lifecycleLog.query({
|
||||
since: range.since,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
status: session.status,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
}));
|
||||
|
||||
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
|
||||
const digest = buildAwayDigest({
|
||||
range,
|
||||
lifecycleEntries,
|
||||
runSummaries,
|
||||
sessions,
|
||||
dailyTokenStats: ctx.store.getDailyStats(30),
|
||||
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
|
||||
now: range.until,
|
||||
});
|
||||
|
||||
return { success: true, digest };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const indexHtml = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
|
||||
const panelsJs = readFileSync(join(process.cwd(), 'src/web/public/panels-ui.js'), 'utf-8');
|
||||
const stylesCss = readFileSync(join(process.cwd(), 'src/web/public/styles.css'), 'utf-8');
|
||||
const mobileCss = readFileSync(join(process.cwd(), 'src/web/public/mobile.css'), 'utf-8');
|
||||
|
||||
describe('away digest UI', () => {
|
||||
it('exposes a header entry point and modal shell', () => {
|
||||
expect(indexHtml).toContain('onclick="app.openAwayDigest()"');
|
||||
expect(indexHtml).toContain('aria-label="Open away digest"');
|
||||
expect(indexHtml).toContain('id="awayDigestModal"');
|
||||
expect(indexHtml).toContain('id="awayDigestSummary"');
|
||||
expect(indexHtml).toContain('id="awayDigestSections"');
|
||||
});
|
||||
|
||||
it('offers supported range controls', () => {
|
||||
expect(indexHtml).toContain('data-away-range="since-last-visit"');
|
||||
expect(indexHtml).toContain('data-away-range="1h"');
|
||||
expect(indexHtml).toContain('data-away-range="today"');
|
||||
expect(indexHtml).toContain('data-away-range="24h"');
|
||||
expect(indexHtml).toContain('id="awayDigestCustomSince"');
|
||||
expect(indexHtml).toContain('id="awayDigestCustomUntil"');
|
||||
});
|
||||
|
||||
it('fetches the aggregate endpoint and preserves since-last-visit until successful close', () => {
|
||||
expect(panelsJs).toContain('/api/away-digest');
|
||||
expect(panelsJs).toContain('codeman-away-digest-last-viewed');
|
||||
expect(panelsJs).toContain('openAwayDigest');
|
||||
expect(panelsJs).toContain('closeAwayDigest');
|
||||
});
|
||||
|
||||
it('has desktop and mobile layout styles', () => {
|
||||
expect(stylesCss).toContain('.away-digest-modal');
|
||||
expect(stylesCss).toContain('.away-digest-summary');
|
||||
expect(stylesCss).toContain('.away-digest-item');
|
||||
expect(mobileCss).toContain('.away-digest-modal');
|
||||
expect(mobileCss).toContain('.away-digest-ranges');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAwayDigest, resolveAwayDigestRange } from '../src/web/away-digest.js';
|
||||
import type { LifecycleEntry, RunSummary } from '../src/types.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 5, 7, 12, 0, 0);
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function summary(sessionId: string, events: RunSummary['events']): RunSummary {
|
||||
return {
|
||||
sessionId,
|
||||
sessionName: `${sessionId} name`,
|
||||
startedAt: NOW - 4 * HOUR,
|
||||
lastUpdatedAt: NOW - HOUR,
|
||||
events,
|
||||
stats: {
|
||||
totalRespawnCycles: 0,
|
||||
totalTokensUsed: 0,
|
||||
peakTokens: 0,
|
||||
totalTimeActiveMs: 0,
|
||||
totalTimeIdleMs: 0,
|
||||
errorCount: events.filter((event) => event.severity === 'error').length,
|
||||
warningCount: events.filter((event) => event.severity === 'warning').length,
|
||||
aiCheckCount: 0,
|
||||
lastIdleAt: null,
|
||||
lastWorkingAt: null,
|
||||
stateTransitions: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('away digest', () => {
|
||||
it('resolves since-last-visit after the stored marker and defaults to 24h', () => {
|
||||
expect(resolveAwayDigestRange({ range: 'since-last-visit', lastViewed: NOW - HOUR, now: NOW })).toMatchObject({
|
||||
range: 'since-last-visit',
|
||||
since: NOW - HOUR,
|
||||
until: NOW,
|
||||
});
|
||||
|
||||
expect(resolveAwayDigestRange({ range: 'since-last-visit', now: NOW })).toMatchObject({
|
||||
since: NOW - 24 * HOUR,
|
||||
until: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
it('separates action-required, completed, live, idle, and informational items', () => {
|
||||
const lifecycleEntries: LifecycleEntry[] = [
|
||||
{ ts: NOW - 10_000, event: 'exit', sessionId: 'failed', name: 'Failed Session', exitCode: 2 },
|
||||
{ ts: NOW - 9_000, event: 'exit', sessionId: 'clean', name: 'Clean Exit', exitCode: 0 },
|
||||
{ ts: NOW - 8_000, event: 'mux_died', sessionId: 'mux', name: 'Mux Session' },
|
||||
];
|
||||
|
||||
const digest = buildAwayDigest({
|
||||
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||
lifecycleEntries,
|
||||
runSummaries: [
|
||||
summary('ralph', [
|
||||
{
|
||||
id: 'complete-1',
|
||||
timestamp: NOW - 7_000,
|
||||
type: 'ralph_completion',
|
||||
severity: 'success',
|
||||
title: 'Ralph completion detected',
|
||||
details: 'Phrase: COMPLETE',
|
||||
},
|
||||
]),
|
||||
summary('error-session', [
|
||||
{
|
||||
id: 'error-1',
|
||||
timestamp: NOW - 6_000,
|
||||
type: 'error',
|
||||
severity: 'error',
|
||||
title: 'Session error',
|
||||
details: 'Tool failed',
|
||||
},
|
||||
]),
|
||||
],
|
||||
sessions: [
|
||||
{ id: 'active', name: 'Active Session', status: 'working' },
|
||||
{ id: 'idle', name: 'Idle Session', status: 'idle' },
|
||||
],
|
||||
dailyTokenStats: [
|
||||
{
|
||||
date: '2026-06-07',
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 2_000,
|
||||
estimatedCost: 0.18,
|
||||
sessions: 2,
|
||||
},
|
||||
],
|
||||
subagents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
sessionId: 'active',
|
||||
description: 'Research complete',
|
||||
status: 'completed',
|
||||
lastUpdated: NOW - 5_000,
|
||||
},
|
||||
],
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['failed', 'mux', 'error-session']);
|
||||
expect(digest.sections.completed.map((item) => item.sessionId)).toEqual(['ralph']);
|
||||
expect(digest.sections.stillRunning.map((item) => item.sessionId)).toEqual(['active']);
|
||||
expect(digest.sections.idle.map((item) => item.sessionId)).toEqual(['idle']);
|
||||
expect(digest.sections.informational.map((item) => item.sessionId)).toContain('clean');
|
||||
expect(digest.sections.informational.some((item) => item.source === 'subagent')).toBe(true);
|
||||
expect(digest.totals).toMatchObject({
|
||||
needsAttention: 3,
|
||||
completed: 1,
|
||||
activeSessions: 2,
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 2_000,
|
||||
estimatedCost: 0.18,
|
||||
tokenWindowPrecision: 'day',
|
||||
});
|
||||
expect(digest.dataFreshness).toMatchObject({
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes entries outside the selected range', () => {
|
||||
const digest = buildAwayDigest({
|
||||
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
|
||||
lifecycleEntries: [
|
||||
{ ts: NOW - 2 * HOUR, event: 'mux_died', sessionId: 'old' },
|
||||
{ ts: NOW - 1000, event: 'mux_died', sessionId: 'recent' },
|
||||
],
|
||||
runSummaries: [],
|
||||
sessions: [],
|
||||
dailyTokenStats: [],
|
||||
subagents: [],
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['recent']);
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,72 @@ describe('system-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/away-digest ==========
|
||||
|
||||
describe('GET /api/away-digest', () => {
|
||||
it('returns a classified digest from lifecycle, active sessions, and token stats', async () => {
|
||||
const lifecycleQuery = vi.fn(async () => [
|
||||
{
|
||||
ts: Date.now() - 1000,
|
||||
event: 'exit',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
name: 'Route Session',
|
||||
exitCode: 7,
|
||||
},
|
||||
]);
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: lifecycleQuery,
|
||||
} as never);
|
||||
harness.ctx._session.name = 'Route Session';
|
||||
harness.ctx._session.status = 'working';
|
||||
harness.ctx.store.getDailyStats.mockReturnValue([
|
||||
{
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
estimatedCost: 0.02,
|
||||
sessions: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=1h' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.digest.sections.needsAttention[0]).toMatchObject({
|
||||
sessionId: harness.ctx._sessionId,
|
||||
source: 'lifecycle',
|
||||
});
|
||||
expect(body.digest.sections.stillRunning[0]).toMatchObject({
|
||||
sessionId: harness.ctx._sessionId,
|
||||
source: 'status',
|
||||
});
|
||||
expect(body.digest.totals).toMatchObject({
|
||||
activeSessions: 1,
|
||||
needsAttention: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
tokenWindowPrecision: 'day',
|
||||
});
|
||||
expect(lifecycleQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 1000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid ranges', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=forever' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message ?? body.error).toMatch(/range/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/debug/memory ==========
|
||||
|
||||
describe('GET /api/debug/memory', () => {
|
||||
|
||||
Reference in New Issue
Block a user