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.
116 lines
3.9 KiB
JavaScript
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>
|
|
`;
|
|
}
|