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>
`;
}

View File

@@ -1,5 +1,5 @@
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
import { API_STATE, API_STREAM, API_DISMISS, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
import { API_STATE, API_STREAM, API_DISMISS, API_DISMISS_DEAD, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
import { groupSessionsByProject } from '../utils/status.js';
import { Sidebar } from './Sidebar.js';
import { SessionCard } from './SessionCard.js';
@@ -17,6 +17,7 @@ export function App() {
const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null);
const [sseConnected, setSseConnected] = useState(false);
const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true);
// Background conversation refresh with error tracking
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
@@ -209,6 +210,21 @@ export function App() {
}
}, [fetchState]);
// Dismiss all dead sessions
const dismissDeadSessions = useCallback(async () => {
try {
const res = await fetch(API_DISMISS_DEAD, { method: 'POST' });
const data = await res.json();
if (data.ok) {
fetchState();
} else {
trackError('dismiss-dead', `Failed to clear completed sessions: ${data.error || 'Unknown error'}`);
}
} catch (err) {
trackError('dismiss-dead', `Error clearing completed sessions: ${err.message}`);
}
}, [fetchState]);
// Subscribe to live state updates via SSE
useEffect(() => {
let eventSource = null;
@@ -296,6 +312,22 @@ export function App() {
return projectGroups.filter(g => g.projectDir === selectedProject);
}, [projectGroups, selectedProject]);
// Split sessions into active and dead
const { activeSessions, deadSessions } = useMemo(() => {
const active = [];
const dead = [];
for (const group of filteredGroups) {
for (const session of group.sessions) {
if (session.is_dead) {
dead.push(session);
} else {
active.push(session);
}
}
}
return { activeSessions: active, deadSessions: dead };
}, [filteredGroups]);
// Handle card click - open modal and fetch conversation if not cached
const handleCardClick = useCallback(async (session) => {
modalSessionRef.current = session.session_id;
@@ -394,10 +426,10 @@ export function App() {
` : filteredGroups.length === 0 ? html`
<${EmptyState} />
` : html`
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
<div class="flex flex-wrap gap-4">
${filteredGroups.flatMap(group =>
group.sessions.map(session => html`
<!-- Active Sessions Grid -->
${activeSessions.length > 0 ? html`
<div class="flex flex-wrap gap-4">
${activeSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
@@ -407,9 +439,64 @@ export function App() {
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
`)
)}
</div>
`)}
</div>
` : deadSessions.length > 0 ? html`
<div class="glass-panel flex items-center justify-center rounded-xl py-12 mb-6">
<div class="text-center">
<p class="font-display text-lg text-dim">No active sessions</p>
<p class="mt-1 font-mono text-micro text-dim/70">All sessions have completed</p>
</div>
</div>
` : ''}
<!-- Completed Sessions (Dead) - Collapsible -->
${deadSessions.length > 0 && html`
<div class="mt-8">
<button
onClick=${() => setDeadSessionsCollapsed(!deadSessionsCollapsed)}
class="group flex w-full items-center gap-3 rounded-lg border border-selection/50 bg-surface/50 px-4 py-3 text-left transition-colors hover:border-selection hover:bg-surface/80"
>
<svg
class="h-4 w-4 text-dim transition-transform ${deadSessionsCollapsed ? '' : 'rotate-90'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span class="font-display text-sm font-medium text-dim">
Completed Sessions
</span>
<span class="rounded-full bg-done/15 px-2 py-0.5 font-mono text-micro tabular-nums text-done/70">
${deadSessions.length}
</span>
<div class="flex-1"></div>
<button
onClick=${(e) => { e.stopPropagation(); dismissDeadSessions(); }}
class="rounded-lg border border-selection/80 bg-bg/40 px-3 py-1.5 font-mono text-micro text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
>
Clear All
</button>
</button>
${!deadSessionsCollapsed && html`
<div class="mt-4 flex flex-wrap gap-4">
${deadSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
onClick=${handleCardClick}
conversation=${conversations[session.session_id]}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
`)}
</div>
`}
</div>
`}
`}
</main>
</div>

View File

@@ -74,6 +74,21 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
}
}, [filteredSkills.length, selectedIndex]);
// Click outside dismisses dropdown
useEffect(() => {
if (!showAutocomplete) return;
const handleClickOutside = (e) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
textareaRef.current && !textareaRef.current.contains(e.target)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showAutocomplete]);
// Insert a selected skill into the text
const insertSkill = useCallback((skill) => {
if (!triggerInfo || !autocompleteConfig) return;

View File

@@ -88,6 +88,25 @@ body {
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}
/* Agent activity spinner */
.activity-spinner {
width: 8px;
height: 8px;
border-radius: 50%;
background: #5fd0a4;
position: relative;
}
.activity-spinner::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
border: 1.5px solid transparent;
border-top-color: #5fd0a4;
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}
/* Working indicator at bottom of chat */
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); }