Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/bundled/agents/AgentCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
gap: var(--space-2);
outline: none;
position: relative;
}
.slot {
display: grid;
min-width: 0;
}
.slot[data-dragging] {
/* Hide the original visually without dropping its keyboard focus. */
opacity: 0;
}
.lifted {
display: grid;
height: 100%;
transform: scale(0.98);
border-radius: var(--radius-container);
box-shadow: var(--shadow-sm);
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.lifted {
transform: none;
}
}
/* Import actions need room for the selected type; scale their tracks with
interface text instead of forcing two narrow cards at small viewports. */
Expand Down
198 changes: 198 additions & 0 deletions src/bundled/agents/AgentInventoryDrag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDndMonitor,
useSensor,
useSensors,
type DragMoveEvent,
} from "@dnd-kit/core";
import { motion, useReducedMotion } from "motion/react";
import { useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { Agent } from "../../features/agents/service";
import { moveAgent } from "./agent-order";
import styles from "./AgentCard.module.css";

/** Owns only the temporary display preview; the inventory commits a completed move. */
export function AgentInventoryDrag({
agents,
enabled,
onMove,
overlay,
children,
}: {
agents: readonly Agent[];
enabled: boolean;
onMove(agents: readonly Agent[]): void;
overlay(agent: Agent): ReactNode;
children(agents: readonly Agent[], dragging: boolean): ReactNode;
}) {
const root = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const [activeKey, setActiveKey] = useState<string>();
const [preview, setPreview] = useState<readonly string[]>();
const byKey = new Map(agents.map((agent) => [agent.pubkey, agent]));
const activeAgent = activeKey ? byKey.get(activeKey) : undefined;
// Inventory changes discard only the transient preview, never mounted cards.
const validPreview =
preview?.length === agents.length && preview.every((key) => byKey.has(key));
const shown = validPreview
? preview.flatMap((key) => byKey.get(key) ?? [])
: agents;
const current = useRef(shown);
current.current = shown;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
}),
);
const move = ({ active }: DragMoveEvent) => {
const rect = active.rect.current.translated;
const grid = root.current?.querySelector<HTMLElement>("[data-agent-grid]");
if (!rect || !grid) return;
const bounds = grid.getBoundingClientRect();
const x = rect.left + rect.width / 2 - bounds.left;
const y = rect.top + rect.height / 2 - bounds.top;
const slots = Array.from(
grid.querySelectorAll<HTMLElement>("[data-agent-slot]"),
);
// Untransformed layout coordinates: animated neighbours must not move the target.
let nearest = 0;
let distance = Infinity;
slots.forEach((slot, index) => {
const next = Math.hypot(
x - slot.offsetLeft - slot.offsetWidth / 2,
y - slot.offsetTop - slot.offsetHeight / 2,
);
if (next < distance) {
nearest = index;
distance = next;
}
});
const from = current.current.findIndex(
(agent) => agent.pubkey === active.id,
);
if (from >= 0 && from !== nearest) {
current.current = moveAgent(current.current, from, nearest);
setPreview(current.current.map((agent) => agent.pubkey));
}
};
const clear = () => {
setActiveKey(undefined);
setPreview(undefined);
};
return (
<DndContext
sensors={sensors}
accessibility={{
container: document.body,
announcements: {
onDragStart: ({ active }) =>
`Picked up ${byKey.get(String(active.id))?.name ?? "agent"}. Escape cancels the move.`,
onDragEnd: () => "Agent move ended.",
onDragCancel: () => "Agent move cancelled.",
onDragOver: () => undefined,
},
}}
onDragStart={({ active }) => {
setPreview(undefined);
if (enabled) setActiveKey(String(active.id));
}}
onDragMove={move}
onDragCancel={clear}
onDragEnd={() => {
if (validPreview && activeAgent) onMove(current.current);
clear();
}}
>
<div ref={root} className="contents">
{children(shown, !!activeAgent)}
</div>
{createPortal(
<DragOverlay
// Matches duration-settle / easing-settle; pointer tracking itself is immediate.
dropAnimation={
reduced
? null
: { duration: 220, easing: "cubic-bezier(.645,.045,.355,1)" }
}
>
{activeAgent && (
<div className={styles.lifted} aria-hidden="true">
{overlay(activeAgent)}
</div>
)}
</DragOverlay>,
document.body,
)}
</DndContext>
);
}

/** Card controls never activate the hold gesture, and keep their own tab stops. */
export function DraggableAgentRow({
agent,
enabled,
children,
}: {
agent: Agent;
enabled: boolean;
children: ReactNode;
}) {
const reduced = useReducedMotion();
const suppressClick = useRef(false);
const { setNodeRef, listeners, isDragging } = useDraggable({
id: agent.pubkey,
disabled: !enabled,
});
useDndMonitor({
onDragStart: ({ active }) => {
if (active.id === agent.pubkey) suppressClick.current = true;
},
});
return (
<motion.div
ref={setNodeRef}
role="row"
className={styles.slot}
data-agent-slot=""
data-dragging={isDragging || undefined}
layout={reduced ? false : "position"}
// JS equivalent of duration-settle / easing-settle.
transition={{
layout: { duration: 0.22, ease: [0.645, 0.045, 0.355, 1] },
}}
onPointerDown={(event) => {
// React portal events bubble here even when the control is outside this row.
if (
!(event.target instanceof Element) ||
!event.currentTarget.contains(event.target)
)
return;
suppressClick.current = false;
if (
event.pointerType === "touch" ||
event.target.closest("[data-card-control], button, input")
)
return;
listeners?.onPointerDown?.(event);
}}
onClickCapture={(event) => {
// Escape can finish a drag before mouseup, after dnd-kit's click guard expires.
if (
suppressClick.current &&
event.detail !== 0 &&
event.target instanceof Node &&
event.currentTarget.contains(event.target)
) {
event.preventDefault();
event.stopPropagation();
}
}}
>
{children}
</motion.div>
);
}
38 changes: 37 additions & 1 deletion src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ function setup(
saved: Agent[],
{
shown = true,
scope = undefined as string | undefined,
model = false,
status = "ready" as AgentsSnapshot["status"],
more = [] as RegisteredAgentType[],
Expand Down Expand Up @@ -385,7 +386,7 @@ function setup(
approveUpdate: vi.fn(),
configHistory: () => undefined,
} satisfies Agents;
const disconnected = { status: "disconnected", generation: 0 };
const disconnected = { status: "disconnected", generation: 0, scope };
const relay = {
snapshot: () => disconnected,
subscribe: () => () => {},
Expand Down Expand Up @@ -3407,3 +3408,38 @@ it("offers Make main agent on a stand-in for a missing chosen agent", async () =
).queryByRole("menuitem", { name: "Make main agent" }),
).toBeNull();
});

it("restores scoped card order without menu reorder actions or remounting neighbours", async () => {
const user = userEvent.setup();
const ada = agent("Ada", 1);
const bo = agent("Bo", 2);
const nova = agent("Nova", 3);
const scope = "https://relay.example:owner";
const view = setup([ada, bo], { scope });
const order = () =>
screen
.getAllByRole("gridcell")
.map((card) => card.getAttribute("aria-label"));
await user.click(screen.getByRole("button", { name: "Actions for Ada" }));
const menu = await screen.findByRole("menu", { name: "Actions for Ada" });
expect(
within(menu).queryByRole("menuitem", { name: "Move earlier" }),
).toBeNull();
expect(
within(menu).queryByRole("menuitem", { name: "Move later" }),
).toBeNull();
act(() => view.show([ada, bo, nova]));
expect(menu).toBeInTheDocument();
await user.keyboard("{Escape}");
act(() => writeView(scope, "agents:order", [bo.pubkey, ada.pubkey]));
expect(order()).toEqual(["Bo", "Ada", "Nova"]);
await user.click(screen.getByRole("gridcell", { name: "Bo" }));
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("gridcell", { name: "Ada" })).toHaveFocus();
view.unmount();
const restored = setup([ada, bo, nova, agent("New", 4)], { scope });
expect(order()).toEqual(["Bo", "Ada", "Nova", "New"]);
restored.unmount();
setup([ada, bo], { scope: "https://other.example:owner" });
expect(order()).toEqual(["Ada", "Bo"]);
});
Loading
Loading