refactor(dashboard): change SpawnModal from overlay modal to dropdown

Position the spawn modal directly under the 'New Agent' button without a
blur overlay. Uses click-outside dismissal and absolute positioning.
Reduces visual disruption for quick agent spawning.
This commit is contained in:
teernisse
2026-02-26 17:10:41 -05:00
parent 7a9d290cb9
commit baa712ba15
42 changed files with 86 additions and 61 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
[ 253ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63
[ 586ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:7400/favicon.ico:0
[ 5076ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:7400/api/projects:0

View File

@@ -0,0 +1,8 @@
[ 391ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63
[ 992ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/api/health:0
[ 1002ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/favicon.ico:0
[ 18428ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/api/projects:0
[ 30877ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/api/health:0
[ 60860ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/api/health:0
[ 73266ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:7400/api/state:0
[ 73266ms] [ERROR] [state-fetch] Failed to fetch state: Failed to fetch @ http://127.0.0.1:7400/components/Toast.js:96

View File

@@ -0,0 +1,2 @@
[ 115ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63
[ 619ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/favicon.ico:0

View File

@@ -0,0 +1,3 @@
[ 176ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63
[ 928ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://127.0.0.1:7400/favicon.ico:0
[ 10844ms] [ERROR] [state-fetch] Failed to fetch state: Request timed out @ http://127.0.0.1:7400/components/Toast.js:96

View File

@@ -0,0 +1,2 @@
[ 286ms] [WARNING] cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in production, install it as a PostCSS plugin or use the Tailwind CLI: https://tailwindcss.com/docs/installation @ https://cdn.tailwindcss.com/:63
[ 10607ms] [ERROR] [state-fetch] Failed to fetch state: Request timed out @ http://127.0.0.1:7400/components/Toast.js:96

Binary file not shown.

Binary file not shown.

View File

@@ -471,6 +471,7 @@ export function App() {
`; `;
})()} })()}
</div> </div>
<div class="relative">
<button <button
disabled=${!zellijAvailable} 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' : ''}" 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' : ''}"
@@ -478,6 +479,13 @@ export function App() {
> >
+ New Agent + New Agent
</button> </button>
<${SpawnModal}
isOpen=${spawnModalOpen}
onClose=${() => setSpawnModalOpen(false)}
onSpawn=${handleSpawnResult}
currentProject=${selectedProject}
/>
</div>
</div> </div>
</header> </header>
@@ -589,12 +597,5 @@ export function App() {
/> />
<${ToastContainer} /> <${ToastContainer} />
<${SpawnModal}
isOpen=${spawnModalOpen}
onClose=${() => setSpawnModalOpen(false)}
onSpawn=${handleSpawnResult}
currentProject=${selectedProject}
/>
`; `;
} }

View File

@@ -18,10 +18,12 @@ export function Modal({ session, conversations, onClose, onRespond, onFetchConve
return; return;
} }
let stale = false;
const agent = session.agent || 'claude'; const agent = session.agent || 'claude';
fetchSkills(agent) fetchSkills(agent)
.then(config => setAutocompleteConfig(config)) .then(config => { if (!stale) setAutocompleteConfig(config); })
.catch(() => setAutocompleteConfig(null)); .catch(() => { if (!stale) setAutocompleteConfig(null); });
return () => { stale = true; };
}, [session?.agent]); }, [session?.agent]);
// Animated close handler // Animated close handler

View File

@@ -110,9 +110,9 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}"> <span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
${agent} ${agent}
</span> </span>
${session.cwd && html` ${session.project_dir && html`
<span class="truncate rounded-full border border-selection bg-bg/40 px-2.5 py-1 font-mono text-micro text-dim"> <span class="truncate rounded-full border border-selection bg-bg/40 px-2.5 py-1 font-mono text-micro text-dim">
${session.cwd.split('/').slice(-2).join('/')} ${session.project_dir.split('/').slice(-2).join('/')}
</span> </span>
`} `}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { html, useState, useEffect, useCallback } from '../lib/preact.js'; import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js';
import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js'; import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js';
export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) { export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
@@ -12,11 +12,21 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
const needsProjectPicker = !currentProject; const needsProjectPicker = !currentProject;
// Lock body scroll when modal is open const dropdownRef = useCallback((node) => {
if (node) dropdownNodeRef.current = node;
}, []);
const dropdownNodeRef = useRef(null);
// Click outside dismisses dropdown
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
document.body.style.overflow = 'hidden'; const handleClickOutside = (e) => {
return () => { document.body.style.overflow = ''; }; if (dropdownNodeRef.current && !dropdownNodeRef.current.contains(e.target)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]); }, [isOpen]);
// Reset state on open // Reset state on open
@@ -107,29 +117,26 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
return html` return html`
<div <div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}" ref=${dropdownRef}
onClick=${(e) => e.target === e.currentTarget && handleClose()} 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'}"
>
<div
class="glass-panel w-full max-w-md rounded-2xl ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
onClick=${(e) => e.stopPropagation()} onClick=${(e) => e.stopPropagation()}
> >
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between border-b border-selection/70 px-5 py-4"> <div class="flex items-center justify-between border-b border-selection/70 px-4 py-3">
<h2 class="font-display text-lg font-semibold text-bright">Spawn Agent</h2> <h2 class="font-display text-sm font-semibold text-bright">Spawn Agent</h2>
<button <button
onClick=${handleClose} onClick=${handleClose}
disabled=${loading} disabled=${loading}
class="flex h-7 w-7 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" 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-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex flex-col gap-4 px-5 py-4"> <div class="flex flex-col gap-3 px-4 py-3">
${needsProjectPicker && html` ${needsProjectPicker && html`
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -202,7 +209,7 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-selection/70 px-5 py-3"> <div class="flex items-center justify-end gap-2 border-t border-selection/70 px-4 py-2.5">
<button <button
onClick=${handleClose} onClick=${handleClose}
disabled=${loading} disabled=${loading}
@@ -223,6 +230,5 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
</button> </button>
</div> </div>
</div> </div>
</div>
`; `;
} }

View File

@@ -29,11 +29,11 @@ export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOU
export async function fetchSkills(agent) { export async function fetchSkills(agent) {
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`; const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
try { try {
const response = await fetch(url); const response = await fetchWithTimeout(url);
if (!response.ok) return null; if (!response.ok) return null;
return response.json(); return await response.json();
} catch { } catch {
// Network error or other failure - graceful degradation // Network error, timeout, or JSON parse failure - graceful degradation
return null; return null;
} }
} }

BIN
slice2-verify-1-initial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

BIN
spawn-modal-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

View File

@@ -325,8 +325,8 @@ class TestDismissSession(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) sessions_dir = Path(tmpdir)
sessions_dir.mkdir(exist_ok=True) sessions_dir.mkdir(exist_ok=True)
# Create a file that should NOT be deleted # Create a file that should NOT be deleted (unused - documents test intent)
secret_file = Path(tmpdir).parent / "secret.json" _secret_file = Path(tmpdir).parent / "secret.json"
handler = DummyControlHandler() handler = DummyControlHandler()
with patch.object(control, "SESSIONS_DIR", sessions_dir): with patch.object(control, "SESSIONS_DIR", sessions_dir):

View File

@@ -7,8 +7,7 @@ import json
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch
import io
from amc_server.mixins.conversation import ConversationMixin from amc_server.mixins.conversation import ConversationMixin
from amc_server.mixins.parsing import SessionParsingMixin from amc_server.mixins.parsing import SessionParsingMixin
@@ -72,8 +71,8 @@ class TestServeEvents(unittest.TestCase):
def test_path_traversal_sanitized(self): def test_path_traversal_sanitized(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) events_dir = Path(tmpdir)
# Create a file that path traversal might try to access # Create a file that path traversal might try to access (unused - documents intent)
secret_file = Path(tmpdir).parent / "secret.jsonl" _secret_file = Path(tmpdir).parent / "secret.jsonl"
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir): with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
# Try path traversal # Try path traversal

View File

@@ -6,7 +6,6 @@ Edge cases are prioritized over happy paths.
import json import json
import os import os
import sys
import tempfile import tempfile
import types import types
import unittest import unittest
@@ -193,7 +192,7 @@ class TestAtomicWrite(unittest.TestCase):
path.write_text('{"original": "data"}') path.write_text('{"original": "data"}')
# Mock os.replace to fail after the temp file is written # Mock os.replace to fail after the temp file is written
original_replace = os.replace _original_replace = os.replace # noqa: F841 - documents test setup
def failing_replace(src, dst): def failing_replace(src, dst):
raise PermissionError("Simulated failure") raise PermissionError("Simulated failure")

View File

@@ -128,8 +128,8 @@ class TestServeDashboardFile(unittest.TestCase):
handler._json_error = capture_error handler._json_error = capture_error
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
# Create a file outside the dashboard dir that shouldn't be accessible # Create a file outside the dashboard dir that shouldn't be accessible (unused - documents intent)
secret = Path(tmpdir).parent / "secret.txt" _secret = Path(tmpdir).parent / "secret.txt"
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)): with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
handler._serve_dashboard_file("../secret.txt") handler._serve_dashboard_file("../secret.txt")

View File

@@ -7,7 +7,7 @@ import json
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch
from amc_server.mixins.parsing import SessionParsingMixin from amc_server.mixins.parsing import SessionParsingMixin

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import amc_server.mixins.spawn as spawn_mod import amc_server.mixins.spawn as spawn_mod
from amc_server.mixins.spawn import SpawnMixin, load_projects_cache, _projects_cache, _sanitize_pane_name from amc_server.mixins.spawn import SpawnMixin, load_projects_cache, _sanitize_pane_name
class DummySpawnHandler(SpawnMixin): class DummySpawnHandler(SpawnMixin):
@@ -317,7 +317,7 @@ class TestRateLimiting(unittest.TestCase):
def test_rapid_spawn_same_project_rejected(self): def test_rapid_spawn_same_project_rejected(self):
"""Spawning the same project within cooldown returns 429.""" """Spawning the same project within cooldown returns 429."""
from amc_server.context import _spawn_timestamps, SPAWN_COOLDOWN_SEC from amc_server.context import _spawn_timestamps
_spawn_timestamps.clear() _spawn_timestamps.clear()
# Pretend we just spawned this project # Pretend we just spawned this project
_spawn_timestamps['rapid-project'] = time.monotonic() _spawn_timestamps['rapid-project'] = time.monotonic()

View File

@@ -4,7 +4,7 @@ import tempfile
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch
import amc_server.mixins.state as state_mod import amc_server.mixins.state as state_mod
from amc_server.mixins.state import StateMixin from amc_server.mixins.state import StateMixin

View File

@@ -13,7 +13,7 @@ import tempfile
import types import types
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Import hook module (no .py extension) # Import hook module (no .py extension)
@@ -24,8 +24,8 @@ amc_hook.__file__ = str(hook_path)
code = compile(hook_path.read_text(), hook_path, "exec") code = compile(hook_path.read_text(), hook_path, "exec")
exec(code, amc_hook.__dict__) # noqa: S102 - loading local module exec(code, amc_hook.__dict__) # noqa: S102 - loading local module
# Import spawn mixin # Import spawn mixin (after hook loading - intentional)
import amc_server.mixins.spawn as spawn_mod import amc_server.mixins.spawn as spawn_mod # noqa: E402
# =========================================================================== # ===========================================================================