feat(dashboard): add Zellij unavailable warning banner

This commit is contained in:
teernisse
2026-02-26 17:08:37 -05:00
parent c0ee053d50
commit 5494d76a98

View File

@@ -1,5 +1,5 @@
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js'; import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
import { API_STATE, API_STREAM, API_DISMISS, API_DISMISS_DEAD, 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, API_HEALTH, POLL_MS, fetchWithTimeout } from '../utils/api.js';
import { groupSessionsByProject } from '../utils/status.js'; import { groupSessionsByProject } from '../utils/status.js';
import { Sidebar } from './Sidebar.js'; import { Sidebar } from './Sidebar.js';
import { SessionCard } from './SessionCard.js'; import { SessionCard } from './SessionCard.js';
@@ -20,6 +20,9 @@ export function App() {
const [sseConnected, setSseConnected] = useState(false); const [sseConnected, setSseConnected] = useState(false);
const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true); const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true);
const [spawnModalOpen, setSpawnModalOpen] = useState(false); const [spawnModalOpen, setSpawnModalOpen] = useState(false);
const [zellijAvailable, setZellijAvailable] = useState(true);
const [newlySpawnedIds, setNewlySpawnedIds] = useState(new Set());
const pendingSpawnIdsRef = useRef(new Set());
// Background conversation refresh with error tracking // Background conversation refresh with error tracking
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => { const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
@@ -71,6 +74,34 @@ export function App() {
} }
} }
// Check for newly spawned sessions matching pending spawn IDs
if (pendingSpawnIdsRef.current.size > 0) {
const matched = new Set();
for (const session of newSessions) {
if (session.spawn_id && pendingSpawnIdsRef.current.has(session.spawn_id)) {
matched.add(session.session_id);
pendingSpawnIdsRef.current.delete(session.spawn_id);
}
}
if (matched.size > 0) {
setNewlySpawnedIds(prev => {
const next = new Set(prev);
for (const id of matched) next.add(id);
return next;
});
// Auto-clear highlight after animation duration (2.5s)
for (const id of matched) {
setTimeout(() => {
setNewlySpawnedIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 2500);
}
}
}
// Clean up conversation cache for sessions that no longer exist // Clean up conversation cache for sessions that no longer exist
setConversations(prev => { setConversations(prev => {
const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id)); const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id));
@@ -303,6 +334,25 @@ export function App() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchState, sseConnected]); }, [fetchState, sseConnected]);
// Poll Zellij health status
useEffect(() => {
const checkHealth = async () => {
try {
const response = await fetchWithTimeout(API_HEALTH);
if (response.ok) {
const data = await response.json();
setZellijAvailable(data.zellij_available);
}
} catch {
// Server unreachable - handled by state fetch error
}
};
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
}, []);
// Group sessions by project // Group sessions by project
const projectGroups = groupSessionsByProject(sessions); const projectGroups = groupSessionsByProject(sessions);
@@ -353,6 +403,9 @@ export function App() {
const handleSpawnResult = useCallback((result) => { const handleSpawnResult = useCallback((result) => {
if (result.success) { if (result.success) {
showToast(`${result.agentType} agent spawned for ${result.project}`, 'success'); showToast(`${result.agentType} agent spawned for ${result.project}`, 'success');
if (result.spawnId) {
pendingSpawnIdsRef.current.add(result.spawnId);
}
} else if (result.error) { } else if (result.error) {
showToast(result.error, 'error'); showToast(result.error, 'error');
} }
@@ -419,7 +472,8 @@ export function App() {
})()} })()}
</div> </div>
<button <button
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20" disabled=${!zellijAvailable}
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20 ${!zellijAvailable ? 'opacity-50 cursor-not-allowed' : ''}"
onClick=${() => setSpawnModalOpen(true)} onClick=${() => setSpawnModalOpen(true)}
> >
+ New Agent + New Agent
@@ -427,6 +481,13 @@ export function App() {
</div> </div>
</header> </header>
${!zellijAvailable && html`
<div class="border-b border-attention/50 bg-attention/10 px-6 py-2 text-sm text-attention">
<span class="font-medium">Zellij session not found.</span>
${' '}Agent spawning is unavailable. Start Zellij with: <code class="rounded bg-attention/15 px-1.5 py-0.5 font-mono text-micro">zellij attach infra</code>
</div>
`}
<main class="px-6 pb-6 pt-6"> <main class="px-6 pb-6 pt-6">
${loading ? html` ${loading ? html`
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24"> <div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
@@ -454,6 +515,7 @@ export function App() {
onFetchConversation=${fetchConversation} onFetchConversation=${fetchConversation}
onRespond=${respondToSession} onRespond=${respondToSession}
onDismiss=${dismissSession} onDismiss=${dismissSession}
isNewlySpawned=${newlySpawnedIds.has(session.session_id)}
/> />
`)} `)}
</div> </div>