merge main: resolve db.test.ts import conflict

Combined both branches: TaskStore import from main + rmSync import
from our branch (needed for fresh DB test cleanup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-04 14:58:47 -07:00
38 changed files with 2625 additions and 188 deletions

View File

@@ -96,6 +96,14 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agent-system-filter")).toBe(true);
expect(hasClass(".agent-controls-actions")).toBe(true);
expect(hasClass(".agent-global-controls")).toBe(true);
expect(hasClass(".agent-org-chart-shell")).toBe(true);
expect(hasClass(".agent-org-chart-controls")).toBe(true);
expect(hasClass(".agent-org-chart-viewport")).toBe(true);
expect(hasClass(".agent-org-chart-canvas")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-75")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
expect(hasClass(".agent-board")).toBe(true);
expect(hasClass(".agent-board-card")).toBe(true);
expect(hasClass(".agent-board-card--idle")).toBe(true);
@@ -177,6 +185,8 @@ describe("Agent CSS classes", () => {
expect(orgChartSection).toContain("padding: var(--space-lg)");
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
expect(orgChartSection).toContain("transform-origin: top left");
expect(orgChartSection).toContain("border: 1px solid var(--border)");
expect(orgChartSection).toContain("color: var(--text)");
expect(orgChartSection).toContain("color: var(--text-muted)");

View File

@@ -790,6 +790,57 @@
}
/* === FN-1167: Agent Org Chart + Chain of Command === */
.agent-org-chart-shell {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agent-org-chart-controls {
display: none;
}
.agent-org-chart-controls__zoom-label {
min-width: calc(var(--space-2xl) * 2);
font-family: var(--font-mono);
text-align: center;
color: var(--text-muted);
}
.agent-org-chart-controls__fit-btn {
margin-left: auto;
}
.agent-org-chart-viewport {
overflow: auto;
max-width: 100%;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
.agent-org-chart-canvas {
width: max-content;
transform-origin: top left;
transition: transform var(--transition-fast);
}
.agent-org-chart-canvas--zoom-75 {
transform: scale(0.75);
}
.agent-org-chart-canvas--zoom-100 {
transform: scale(1);
}
.agent-org-chart-canvas--zoom-125 {
transform: scale(1.25);
}
.agent-org-chart-canvas--zoom-150 {
transform: scale(1.5);
}
.agent-org-chart {
--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs));
--org-chart-connector-gap: var(--space-xs);
@@ -798,8 +849,6 @@
justify-content: flex-start;
gap: var(--space-xl);
padding: var(--space-lg);
overflow-x: auto;
overflow-y: visible;
min-height: var(--org-chart-node-width);
}
@@ -1007,6 +1056,25 @@
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
}
.agent-org-chart-shell {
min-height: 0;
}
.agent-org-chart-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.agent-org-chart-controls .btn-icon,
.agent-org-chart-controls .agent-org-chart-controls__fit-btn {
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
.agent-org-chart-viewport {
min-height: calc(var(--space-2xl) * 4);
}
.agent-org-chart {
--org-chart-node-width: calc(var(--space-2xl) * 5);
padding: var(--space-sm);

View File

@@ -1,6 +1,6 @@
import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check } from "lucide-react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
@@ -47,6 +47,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
const SKILL_PATH_LABEL_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/i;
const ORG_CHART_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
export function formatAgentSkillBadgeLabel(skillId: string): string {
const trimmedSkillId = skillId.trim();
@@ -267,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
const controlsPanelRef = useRef<HTMLDivElement>(null);
const { confirm } = useConfirm();
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -706,6 +708,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => {
setAgentView(nextView);
if (nextView !== "org") {
setOrgChartZoomIndex(1);
}
if (isMobileViewport && selectedAgentId) {
handleCloseDetail();
}
@@ -714,6 +719,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
/** Get skill badges from agent metadata */
const getSkillBadges = (agent: Agent): string[] => {
@@ -966,27 +972,68 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<span>Loading agents...</span>
</div>
) : agentView === "org" ? (
<div className="agent-org-chart" data-testid="agent-org-chart">
{isOrgTreeLoading ? (
<div className="agent-org-chart__loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />
<span>Loading org chart...</span>
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
{isMobileViewport ? (
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
<button
type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.max(0, value - 1))}
disabled={orgChartZoomIndex === 0}
aria-label="Zoom out org chart"
title="Zoom out"
>
<ZoomOut size={16} />
</button>
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartZoom * 100)}%</span>
<button
type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.min(ORG_CHART_ZOOM_LEVELS.length - 1, value + 1))}
disabled={orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1}
aria-label="Zoom in org chart"
title="Zoom in"
>
<ZoomIn size={16} />
</button>
<button
type="button"
className="btn touch-target btn-sm agent-org-chart-controls__fit-btn"
onClick={() => setOrgChartZoomIndex(1)}
aria-label="Fit org chart"
title="Fit org chart"
>
<Minimize2 size={16} />
Fit
</button>
</div>
) : displayOrgTree.length === 0 ? (
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
displayOrgTree.map((node) => (
<OrgChartNode
key={node.agent.id}
node={node}
onSelect={openAgentDetail}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
selectedAgentId={selectedAgentId}
/>
))
)}
) : null}
<div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
<div className="agent-org-chart" data-testid="agent-org-chart">
{isOrgTreeLoading ? (
<div className="agent-org-chart__loading" role="status" aria-live="polite">
<RefreshCw size={18} className="spin" />
<span>Loading org chart...</span>
</div>
) : displayOrgTree.length === 0 ? (
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
displayOrgTree.map((node) => (
<OrgChartNode
key={node.agent.id}
node={node}
onSelect={openAgentDetail}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
selectedAgentId={selectedAgentId}
/>
))
)}
</div>
</div>
</div>
</div>
) : agentView === "board" ? (
<div className="agent-board">

View File

@@ -92,7 +92,8 @@
@media (max-width: 768px) {
.modal-overlay.settings-modal-overlay,
.modal-overlay:has(.settings-modal) {
padding-top: 0;
padding: 0;
inset: 0;
align-items: stretch;
justify-content: stretch;
}
@@ -102,16 +103,18 @@
min-width: 0;
max-width: 100vw;
height: 100dvh;
min-height: 0;
min-height: 100dvh;
max-height: 100dvh;
margin: 0;
border: none;
border-radius: 0;
resize: none;
flex: 1 1 auto;
}
.modal.settings-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
min-height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
@@ -366,16 +369,22 @@
.settings-section-heading {
font-size: 14px;
font-weight: 600;
padding: var(--space-lg) 0 var(--space-md);
margin: 0;
padding: var(--space-lg) var(--space-xl) var(--space-md);
margin: 0 0 var(--space-md);
color: var(--text);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-xs);
}
/* First heading inside the section drops top padding to remove a redundant
gap stacked on top of the settings-content container's own top padding. */
.settings-content > .settings-section-heading:first-child,
.settings-modal-section > .settings-section-heading:first-child {
padding-top: var(--space-md);
}
/* Spacing modifier for settings-section-heading that need extra top margin */
.settings-section-heading--spaced {
margin-top: var(--space-xl);
margin-top: var(--space-lg);
}
.settings-section-description {
@@ -1485,8 +1494,8 @@
}
.settings-section-heading {
padding: var(--space-lg) 0 var(--space-md);
margin: 0;
padding: var(--space-lg) var(--space-lg) var(--space-md);
margin: 0 0 var(--space-md);
}
.settings-plugins-subsection-toggle {

View File

@@ -1291,6 +1291,40 @@ describe("AgentsView", () => {
});
});
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchOrgTree.mockResolvedValue(orgTree);
const { container } = render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
const controls = await screen.findByTestId("agent-org-chart-controls");
expect(controls).toBeTruthy();
expect(screen.getByText("100%")).toBeTruthy();
const viewport = screen.getByTestId("agent-org-chart-viewport");
expect(viewport).toBeTruthy();
const canvas = container.querySelector(".agent-org-chart-canvas");
expect(canvas?.className).toContain("agent-org-chart-canvas--zoom-100");
fireEvent.click(within(controls).getByTitle("Zoom in"));
await waitFor(() => {
expect(screen.getByText("125%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-125");
});
fireEvent.click(within(controls).getByTitle("Fit org chart"));
await waitFor(() => {
expect(screen.getByText("100%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
});
fireEvent.click(screen.getByText("Director One"));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
});
});
it("shows org chart empty state when API returns no nodes", async () => {
mockFetchOrgTree.mockResolvedValue([]);
render(<AgentsView addToast={mockAddToast} />);

View File

@@ -259,7 +259,10 @@ describe("agents-view mobile CSS", () => {
expect(block).toContain("flex-wrap: wrap");
});
it("defines mobile org chart sizing rules", () => {
it("defines mobile org chart sizing and pan/zoom controls rules", () => {
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");

View File

@@ -725,7 +725,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -776,7 +776,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -803,7 +803,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -872,7 +872,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -896,7 +896,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -915,7 +915,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -924,7 +924,7 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results[0].success).toBe(true);
expect(res.body.results[0].taskId).toBeDefined();
expect(throttledSpy).toHaveBeenCalledTimes(1);
}, 10000); // Increase timeout for retry delay
});
it("returns error after max retries exceeded on 429", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
@@ -969,7 +969,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -1025,7 +1025,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
@@ -1954,7 +1954,7 @@ describe("GET /tasks/:id/diff", () => {
});
describe("done tasks with commit SHA", () => {
it("attempts git diff when commitSha is present", { timeout: 30_000 }, async () => {
it("attempts git diff when commitSha is present", async () => {
const gitRepo = getSharedGitTestRepo();
const localStore = createMockStore({
getRootDir: vi.fn().mockReturnValue(gitRepo.repoDir),