Files
amc/dashboard/components/SpawnModal.js
teernisse fa1fe8613a feat(dashboard): wire skills autocomplete configuration to session cards
Connect the skills enumeration API to session card input fields for
slash command autocomplete:

App.js:
- Add skillsConfig state for Claude and Codex skill configs
- Fetch skills for both agent types on mount using Promise.all
- Pass agent-appropriate autocompleteConfig to each SessionCard

SessionCard.js:
- Accept autocompleteConfig prop and forward to SimpleInput
- Move context usage display from header to footer status bar for
  better information hierarchy (activity indicator + context together)

SimpleInput.js:
- Fix autocomplete dropdown padding (py-2 -> py-1.5)
- Fix font inheritance (add font-mono to skill name)
- Fix description tooltip whitespace handling (add font-sans,
  whitespace-normal)

SpawnModal.js:
- Add SPAWN_TIMEOUT_MS (2x default) to handle pending spawn registry
  wait time plus session file confirmation polling

AgentActivityIndicator.js:
- Minor styling refinement for status display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:48:26 -05:00

242 lines
9.1 KiB
JavaScript

import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js';
import { API_PROJECTS, API_SPAWN, fetchWithTimeout, API_TIMEOUT_MS } from '../utils/api.js';
// Spawn needs longer timeout: pending spawn registry requires discovery cycle to run,
// plus server polls for session file confirmation
const SPAWN_TIMEOUT_MS = API_TIMEOUT_MS * 2;
export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState('');
const [agentType, setAgentType] = useState('claude');
const [loading, setLoading] = useState(false);
const [loadingProjects, setLoadingProjects] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState(null);
const needsProjectPicker = !currentProject;
const dropdownRef = useCallback((node) => {
if (node) dropdownNodeRef.current = node;
}, []);
const dropdownNodeRef = useRef(null);
// Click outside dismisses dropdown
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (dropdownNodeRef.current && !dropdownNodeRef.current.contains(e.target)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Reset state on open
useEffect(() => {
if (isOpen) {
setAgentType('claude');
setError(null);
setLoading(false);
setClosing(false);
}
}, [isOpen]);
// Fetch projects when needed
useEffect(() => {
if (isOpen && needsProjectPicker) {
setLoadingProjects(true);
fetchWithTimeout(API_PROJECTS)
.then(r => r.json())
.then(data => {
setProjects(data.projects || []);
setSelectedProject('');
})
.catch(err => setError(err.message))
.finally(() => setLoadingProjects(false));
}
}, [isOpen, needsProjectPicker]);
// Animated close handler
const handleClose = useCallback(() => {
if (loading) return;
setClosing(true);
setTimeout(() => {
setClosing(false);
onClose();
}, 200);
}, [loading, onClose]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
const handleSpawn = async () => {
const rawProject = currentProject || selectedProject;
if (!rawProject) {
setError('Please select a project');
return;
}
// Extract project name from full path (sidebar passes projectDir like "/Users/.../projects/amc")
const project = rawProject.includes('/') ? rawProject.split('/').pop() : rawProject;
setLoading(true);
setError(null);
try {
const response = await fetchWithTimeout(API_SPAWN, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`,
},
body: JSON.stringify({ project, agent_type: agentType }),
}, SPAWN_TIMEOUT_MS);
const data = await response.json();
if (data.ok) {
onSpawn({ success: true, project, agentType, spawnId: data.spawn_id });
handleClose();
} else {
setError(data.error || 'Spawn failed');
onSpawn({ error: data.error });
}
} catch (err) {
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
setError(msg);
onSpawn({ error: msg });
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
const canSpawn = !loading && (currentProject || selectedProject);
return html`
<div
ref=${dropdownRef}
class="absolute right-0 top-full mt-2 z-50 glass-panel w-80 rounded-xl border border-selection/70 shadow-lg ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
onClick=${(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-selection/70 px-4 py-3">
<h2 class="font-display text-sm font-semibold text-bright">Spawn Agent</h2>
<button
onClick=${handleClose}
disabled=${loading}
class="flex h-6 w-6 items-center justify-center rounded-lg border border-selection/80 text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-3 px-4 py-3">
${needsProjectPicker && html`
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Project</label>
${loadingProjects ? html`
<div class="flex items-center gap-2 rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-dim">
<span class="working-dots"><span>.</span><span>.</span><span>.</span></span>
Loading projects
</div>
` : projects.length === 0 ? html`
<div class="rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-dim">
No projects found in ~/projects/
</div>
` : html`
<select
value=${selectedProject}
onChange=${(e) => { setSelectedProject(e.target.value); setError(null); }}
class="w-full rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 focus:border-starting/60 focus:outline-none"
>
<option value="" disabled>Select a project...</option>
${projects.map(p => html`
<option key=${p} value=${p}>${p}</option>
`)}
</select>
`}
</div>
`}
${currentProject && html`
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Project</label>
<div class="rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-bright">
${currentProject.includes('/') ? currentProject.split('/').pop() : currentProject}
</div>
</div>
`}
<!-- Agent type -->
<div class="flex flex-col gap-1.5">
<label class="text-label font-medium text-dim">Agent Type</label>
<div class="flex gap-2">
<button
onClick=${() => setAgentType('claude')}
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition-colors duration-150 ${
agentType === 'claude'
? 'border-violet-400/45 bg-violet-500/14 text-violet-300'
: 'border-selection/75 bg-bg/70 text-dim hover:border-selection hover:text-fg'
}"
>
Claude
</button>
<button
onClick=${() => setAgentType('codex')}
class="flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition-colors duration-150 ${
agentType === 'codex'
? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300'
: 'border-selection/75 bg-bg/70 text-dim hover:border-selection hover:text-fg'
}"
>
Codex
</button>
</div>
</div>
${error && html`
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
${error}
</div>
`}
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-selection/70 px-4 py-2.5">
<button
onClick=${handleClose}
disabled=${loading}
class="rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm font-medium text-dim transition-colors hover:border-selection hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
onClick=${handleSpawn}
disabled=${!canSpawn}
class="rounded-xl px-4 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:brightness-100 ${
agentType === 'claude'
? 'bg-violet-500 text-white'
: 'bg-emerald-500 text-white'
}"
>
${loading ? 'Spawning...' : 'Spawn'}
</button>
</div>
</div>
`;
}