COD-8 add away digest

This commit is contained in:
Aamer Akhter
2026-06-19 17:58:06 -04:00
parent 1255e28f6f
commit c95e94e4cb
9 changed files with 1273 additions and 0 deletions
+39
View File
@@ -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">&#x21BB; Refresh</button>
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">&times;</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>
+38
View File
@@ -1163,6 +1163,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;
+274
View File
@@ -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
// ═══════════════════════════════════════════════════════════════
+232
View File
@@ -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;