Files
amc/dashboard/components/AgentActivityIndicator.js
teernisse db3d2a2e31 feat(dashboard): add click-outside dismissal for autocomplete dropdown
Closes bd-3ny. Added mousedown listener that dismisses the dropdown when
clicking outside both the dropdown and textarea. Uses early return to avoid
registering listeners when dropdown is already closed.
2026-02-26 16:54:40 -05:00

116 lines
3.9 KiB
JavaScript

import { html, useState, useEffect, useRef } from '../lib/preact.js';
/**
* Shows live agent activity: elapsed time since user prompt, token usage.
* Visible when session is active/starting, pauses during needs_attention,
* shows final duration when done.
*
* @param {object} session - Session object with turn_started_at, turn_paused_at, turn_paused_ms, status
*/
export function AgentActivityIndicator({ session }) {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
// Safely extract session fields (handles null/undefined session)
const status = session?.status;
const turn_started_at = session?.turn_started_at;
const turn_paused_at = session?.turn_paused_at;
const turn_paused_ms = session?.turn_paused_ms ?? 0;
const last_event_at = session?.last_event_at;
const context_usage = session?.context_usage;
const turn_start_tokens = session?.turn_start_tokens;
// Only show for sessions with turn timing
const hasTurnTiming = !!turn_started_at;
const isActive = status === 'active' || status === 'starting';
const isPaused = status === 'needs_attention';
const isDone = status === 'done';
useEffect(() => {
if (!hasTurnTiming) return;
const calculate = () => {
const startMs = new Date(turn_started_at).getTime();
const pausedMs = turn_paused_ms || 0;
if (isActive) {
// Running: current time - start - paused
return Date.now() - startMs - pausedMs;
} else if (isPaused && turn_paused_at) {
// Paused: frozen at pause time
const pausedAtMs = new Date(turn_paused_at).getTime();
return pausedAtMs - startMs - pausedMs;
} else if (isDone && last_event_at) {
// Done: final duration
const endMs = new Date(last_event_at).getTime();
return endMs - startMs - pausedMs;
}
return 0;
};
setElapsed(calculate());
// Only tick while active
if (isActive) {
intervalRef.current = setInterval(() => {
setElapsed(calculate());
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [hasTurnTiming, isActive, isPaused, isDone, turn_started_at, turn_paused_at, turn_paused_ms, last_event_at]);
// Don't render if no turn timing or session is done with no activity
if (!hasTurnTiming) return null;
// Format elapsed time (clamp to 0 for safety)
const formatElapsed = (ms) => {
const totalSec = Math.max(0, Math.floor(ms / 1000));
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}m ${sec}s`;
};
// Format token count
const formatTokens = (count) => {
if (count == null) return null;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return String(count);
};
// Calculate turn tokens (current - baseline from turn start)
const currentTokens = context_usage?.current_tokens;
const turnTokens = (currentTokens != null && turn_start_tokens != null)
? Math.max(0, currentTokens - turn_start_tokens)
: null;
const tokenDisplay = formatTokens(turnTokens);
return html`
<div class="flex items-center gap-2 px-3 py-2 border-b border-selection/50 bg-bg/60 font-mono text-label">
${isActive && html`
<span class="activity-spinner"></span>
`}
${isPaused && html`
<span class="h-2 w-2 rounded-full bg-attention"></span>
`}
${isDone && html`
<span class="h-2 w-2 rounded-full bg-done"></span>
`}
<span class="text-dim">
${isActive ? 'Working' : isPaused ? 'Paused' : 'Completed'}
</span>
<span class="text-bright tabular-nums">${formatElapsed(elapsed)}</span>
${tokenDisplay && html`
<span class="text-dim/70">·</span>
<span class="text-dim/90">${tokenDisplay} tokens</span>
`}
</div>
`;
}