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.
This commit is contained in:
teernisse
2026-02-26 16:52:36 -05:00
parent ba16daac2a
commit db3d2a2e31
35 changed files with 5560 additions and 104 deletions

View File

@@ -0,0 +1,115 @@
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>
`;
}