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:
115
dashboard/components/AgentActivityIndicator.js
Normal file
115
dashboard/components/AgentActivityIndicator.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user