diff --git a/src/bundled/agents/AgentCard.module.css b/src/bundled/agents/AgentCard.module.css index 39708ee0a3..15c3bb456b 100644 --- a/src/bundled/agents/AgentCard.module.css +++ b/src/bundled/agents/AgentCard.module.css @@ -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. */ diff --git a/src/bundled/agents/AgentInventoryDrag.tsx b/src/bundled/agents/AgentInventoryDrag.tsx new file mode 100644 index 0000000000..64e8e618f9 --- /dev/null +++ b/src/bundled/agents/AgentInventoryDrag.tsx @@ -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(null); + const reduced = useReducedMotion(); + const [activeKey, setActiveKey] = useState(); + const [preview, setPreview] = useState(); + 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("[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("[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 ( + + `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(); + }} + > +
+ {children(shown, !!activeAgent)} +
+ {createPortal( + + {activeAgent && ( + + )} + , + document.body, + )} +
+ ); +} + +/** 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 ( + { + // 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} + + ); +} diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index d183ce6835..92e2a4029b 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -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[], @@ -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: () => () => {}, @@ -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"]); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index f1a3337203..4832c00cd6 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -33,6 +33,8 @@ import { } from "./AgentBrowse"; import { AgentShareDialog } from "./AgentShareDialog"; import { AgentCard, AgentPortrait, TeamCard } from "./AgentCard"; +import { AgentInventoryDrag, DraggableAgentRow } from "./AgentInventoryDrag"; +import { useAgentOrder } from "./agent-order"; import { AgentCreateDialog, TypeIcon } from "./AgentCreateDialog"; import { NewAgentTile } from "./NewAgentTile"; import type { ChannelChoice } from "../../features/agents/attention"; @@ -433,7 +435,11 @@ export function AgentsPage({ {...inventory} /> ) : ( - + ); })(); @@ -669,6 +675,7 @@ export function AgentsPage({ } type InventoryProps = { + scope?: string | undefined; service: Agents; agents: readonly Agent[]; selected: string | undefined; @@ -700,6 +707,8 @@ function DefaultAgentInventory({ <> + ( + } + detail={summaryOf(agent, typeOf(agent))} + /> + )} + > + {(preview, dragging) => ( + + )} + + {error && ( +

+ {error} +

+ )} + + ); +} + +function InventoryGrid({ + scope, service, agents, selected, @@ -787,7 +833,10 @@ function Inventory({ defaultChoices, onMakeDefault, session, -}: InventoryProps) { + dragging, +}: InventoryProps & { + dragging: boolean; +}) { const grid = useRef(null); const index = agents.findIndex((agent) => agent.pubkey === selected); const columns = () => @@ -795,6 +844,7 @@ function Inventory({ ? getComputedStyle(grid.current).gridTemplateColumns.split(" ").length : 1; const onKeyDown = (event: KeyboardEvent) => { + if (dragging) return; if ( !(event.target instanceof HTMLElement) || event.target.getAttribute("role") !== "gridcell" @@ -833,7 +883,7 @@ function Inventory({ [next]?.focus(); }; return ( -
+
{newAgent} {agents.length > 0 && ( // biome-ignore lint/a11y/useSemanticElements: This is a wrapping layout grid of interactive cards, not tabular data. @@ -848,9 +898,11 @@ function Inventory({ const active = agent.pubkey === selected; const isMain = agent.pubkey === defaultAgent; return ( - // biome-ignore lint/a11y/useSemanticElements: Layout grid rows group cards without table layout. - // biome-ignore lint/a11y/useFocusableInteractive: Roving focus belongs to the gridcell, not its row wrapper. -
+ } detail={summaryOf(agent, type)} /> -
+ ); })}
diff --git a/src/bundled/agents/agent-order.test.tsx b/src/bundled/agents/agent-order.test.tsx new file mode 100644 index 0000000000..b4e66ef127 --- /dev/null +++ b/src/bundled/agents/agent-order.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Agent } from "../../features/agents/service"; +import { readView, writeView } from "../../shared/view-state"; +import { moveAgent, useAgentOrder } from "./agent-order"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +it("keeps the saved order after a failed save, retries, and reconciles removed agents", () => { + const scope = "https://relay.example:owner"; + const ada = { pubkey: "1".repeat(64), name: "Ada" } as Agent; + const bo = { pubkey: "2".repeat(64), name: "Bo" } as Agent; + const next = { pubkey: "3".repeat(64), name: "New" } as Agent; + writeView(scope, "agents:order", { malformed: true }); + const { result, rerender } = renderHook( + ({ agents }) => useAgentOrder(scope, agents), + { initialProps: { agents: [ada, bo] } }, + ); + expect(result.current.ordered).toEqual([ada, bo]); + act(() => result.current.save([ada, bo])); + const moved = moveAgent(result.current.ordered, 0, 1); + const storage = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + try { + act(() => result.current.save(moved)); + expect(result.current.error).toContain("order couldn't be saved"); + expect(result.current.ordered).toEqual([ada, bo]); + } finally { + storage.mockRestore(); + } + act(() => result.current.save(moved)); + expect(result.current.error).toBe(""); + expect(result.current.ordered).toEqual([bo, ada]); + rerender({ agents: [bo, next] }); + expect(result.current.ordered).toEqual([bo, next]); +}); + +it.each([ + ["before", ["H", "A", "B"], ["H", "B", "A", "N"]], + ["between", ["A", "H", "B"], ["B", "H", "A", "N"]], + ["after", ["A", "B", "H"], ["B", "A", "H", "N"]], +])( + "preserves temporarily absent agents %s a reordered subset", + (_, initial, expected) => { + const scope = "https://relay.example:owner"; + const a = { pubkey: "1".repeat(64), name: "A" } as Agent; + const b = { pubkey: "2".repeat(64), name: "B" } as Agent; + const h = { + pubkey: "3".repeat(64), + name: "H", + place: "elsewhere", + } as Agent; + const n = { pubkey: "4".repeat(64), name: "N" } as Agent; + const keys = new Map( + [a, b, h, n].map((agent) => [agent.name, agent.pubkey]), + ); + writeView( + scope, + "agents:order", + initial.map((name) => keys.get(name)), + ); + const { result, rerender, unmount } = renderHook( + ({ agents }) => useAgentOrder(scope, agents), + { initialProps: { agents: [a, b] } }, + ); + act(() => result.current.save([b, a])); + const returning = [a, h, b, n]; + rerender({ agents: returning }); + expect(result.current.ordered.map((agent) => agent.name)).toEqual(expected); + unmount(); + const restored = renderHook(() => useAgentOrder(scope, returning)); + expect(restored.result.current.ordered.map((agent) => agent.name)).toEqual( + expected, + ); + // A subsequent move still retains the absent slot and appends a new key once. + restored.unmount(); + const partial = renderHook(() => useAgentOrder(scope, [a, b, n])); + act(() => partial.result.current.save([b, a, n])); + expect(readView(scope, "agents:order", [])).toEqual( + expected.map((name) => keys.get(name)), + ); + }, +); diff --git a/src/bundled/agents/agent-order.ts b/src/bundled/agents/agent-order.ts new file mode 100644 index 0000000000..2cf8b69851 --- /dev/null +++ b/src/bundled/agents/agent-order.ts @@ -0,0 +1,65 @@ +import { useCallback, useState, useSyncExternalStore } from "react"; +import type { Agent } from "../../features/agents/service"; +import { + readView, + subscribeView, + viewRevision, + writeView, +} from "../../shared/view-state"; + +const KEY = "agents:order"; + +/** Display order only: never changes the agent service, teams or main agent. */ +export function useAgentOrder( + scope: string | undefined, + agents: readonly Agent[], +) { + useSyncExternalStore( + useCallback( + (notify) => (scope ? subscribeView(scope, notify) : () => {}), + [scope], + ), + () => (scope ? viewRevision(scope, KEY) : null), + ); + const [error, setError] = useState(""); + const saved = scope ? readView(scope, KEY, []) : []; + const savedKeys = [ + ...new Set( + (Array.isArray(saved) ? saved : []).filter( + (key): key is string => + typeof key === "string" && /^[0-9a-f]{64}$/.test(key), + ), + ), + ]; + const ranks = new Map(savedKeys.map((key, index) => [key, index])); + const ordered = [...agents].sort( + (a, b) => + (ranks.get(a.pubkey) ?? Infinity) - (ranks.get(b.pubkey) ?? Infinity), + ); + const save = (next: readonly Agent[]) => { + const keys = next.map((agent) => agent.pubkey); + const visible = new Set(keys); + let index = 0; + // An incomplete inventory is not deletion evidence. Replace only visible + // slots, retain absent saved keys, then append any newly seen agents. + const merged = savedKeys.map((key) => + visible.has(key) ? keys[index++] : key, + ); + merged.push(...keys.slice(index)); + const saved = !!scope && writeView(scope, KEY, merged); + setError( + saved + ? "" + : "The agent order couldn't be saved on this device. Try again.", + ); + return saved; + }; + return { ordered, save, error }; +} + +export function moveAgent(agents: readonly Agent[], from: number, to: number) { + const next = [...agents]; + const [agent] = next.splice(from, 1); + if (agent) next.splice(to, 0, agent); + return next; +} diff --git a/tests/browser/agent-reorder.spec.mjs b/tests/browser/agent-reorder.spec.mjs new file mode 100644 index 0000000000..e3f013326c --- /dev/null +++ b/tests/browser/agent-reorder.spec.mjs @@ -0,0 +1,158 @@ +import { test, expect } from "./source-fixture.mjs"; + +// Real hit testing and grid geometry are required: jsdom cannot prove the live +// preview, pointer tracking, or that embedded controls stay outside the gesture. +test("holding an agent previews a move, cancels cleanly, and saves only on drop", async ({ + page, +}) => { + await page.clock.install(); + await page.goto("/tests/fixtures/agent-library.html?reorder"); + const grid = page.getByRole("grid", { name: "Agents", exact: true }); + const cards = grid.locator('[role="gridcell"]'); + const order = () => + cards.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("aria-label")), + ); + await expect.poll(order).toEqual(["Ada", "Bo", "Cy"]); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + const ada = grid.getByRole("gridcell", { name: "Ada", exact: true }); + const cy = grid.getByRole("gridcell", { name: "Cy", exact: true }); + const start = await ada.boundingBox(); + const end = await cy.boundingBox(); + if (!start || !end) throw new Error("Cards must have layout"); + const press = async () => { + await page.mouse.move( + start.x + start.width / 2, + start.y + start.height / 3, + ); + await page.mouse.down(); + await page.clock.runFor(249); + await expect(page.locator("[data-dragging]")).toHaveCount(0); + await page.clock.runFor(1); + await expect(page.locator("[data-dragging]")).toHaveCount(1); + }; + await press(); + await page.mouse.move(end.x + end.width / 2, end.y + end.height / 3, { + steps: 8, + }); + await expect.poll(order).toEqual(["Bo", "Cy", "Ada"]); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect.poll(order).toEqual(["Ada", "Bo", "Cy"]); + await page.clock.runFor(300); + // dnd-kit's Web Animation uses real time, not the installed test clock. + await expect( + page.locator('[aria-hidden="true"]').getByText("Ada", { exact: true }), + ).toHaveCount(0); + await press(); + await page.mouse.move(end.x + end.width / 2, end.y + end.height / 3, { + steps: 8, + }); + await expect.poll(order).toEqual(["Bo", "Cy", "Ada"]); + await page.mouse.up(); + await page.clock.runFor(300); + await expect(page.getByRole("complementary")).toHaveCount(0); + await page.reload(); + await expect.poll(order).toEqual(["Bo", "Cy", "Ada"]); + // Holding the model button must not pick the card up. + const model = ada.getByRole("combobox", { name: "Model", exact: true }); + const button = await model.boundingBox(); + if (!button) throw new Error("Model button must have layout"); + await page.mouse.move( + button.x + button.width / 2, + button.y + button.height / 2, + ); + await page.mouse.down(); + await page.clock.runFor(300); + await expect(page.locator("[data-dragging]")).toHaveCount(0); + await page.mouse.up(); + await expect(page.getByRole("listbox")).toBeVisible(); + const option = page.getByRole("option", { + name: "Example model", + exact: true, + }); + const optionBounds = await option.boundingBox(); + if (!optionBounds) throw new Error("Model option must have layout"); + await page.mouse.move( + optionBounds.x + optionBounds.width / 2, + optionBounds.y + optionBounds.height / 2, + ); + await page.mouse.down(); + try { + await page.clock.runFor(300); + await expect(page.locator("[data-dragging]")).toHaveCount(0); + } finally { + await page.mouse.up(); + } + await expect(model).toHaveAttribute("aria-expanded", "false"); + // The controlled clock must also let the Select's exit transition finish. + await page.clock.runFor(300); + await expect(page.getByRole("listbox")).toHaveCount(0); + // A drag has its own Escape contract, even while a selected card has a panel. + await ada.click(); + const panel = page.getByRole("region", { name: "Agent profile" }); + await expect(panel).toBeVisible(); + await page.clock.runFor(300); + const selected = await ada.boundingBox(); + if (!selected) throw new Error("Selected card must have layout"); + await ada.focus(); + await page.mouse.move( + selected.x + selected.width / 2, + selected.y + selected.height / 3, + ); + await page.mouse.down(); + try { + await page.clock.runFor(250); + await expect(page.locator("[data-dragging]")).toHaveCount(1); + await page.keyboard.press("ArrowLeft"); + await expect(ada).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Escape"); + await expect(page.locator("[data-dragging]")).toHaveCount(0); + await expect(panel).toBeVisible(); + await expect(ada).toHaveAttribute("aria-selected", "true"); + // Expire the timer-based click guard, then await the real-time drop animation. + await page.clock.runFor(300); + await expect( + page.locator('[aria-hidden="true"]').getByText("Ada", { exact: true }), + ).toHaveCount(0); + } finally { + await page.mouse.up(); + } + await expect(panel).toBeVisible(); + await expect(ada).toHaveAttribute("aria-selected", "true"); +}); + +test("Escape followed by a delayed release does not click the card", async ({ + page, +}) => { + await page.clock.install(); + await page.goto("/tests/fixtures/agent-library.html?reorder"); + const card = page.getByRole("gridcell", { name: "Ada", exact: true }); + await expect(card).toBeVisible(); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + const bounds = await card.boundingBox(); + if (!bounds) throw new Error("Card must have layout"); + await page.mouse.move( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 3, + ); + await page.mouse.down(); + try { + await page.clock.runFor(250); + await expect(page.locator("[data-dragging]")).toHaveCount(1); + await page.keyboard.press("Escape"); + await page.clock.runFor(300); + await expect(page.getByText("Ada", { exact: true })).toHaveCount(1); + } finally { + await page.mouse.up(); + } + await expect(card).toHaveAttribute("aria-selected", "false"); + await expect(page.getByRole("region", { name: "Agent profile" })).toHaveCount( + 0, + ); + // A subsequent ordinary click is not swallowed. + await card.click(); + await expect( + page.getByRole("region", { name: "Agent profile" }), + ).toBeVisible(); +}); diff --git a/tests/fixtures/agent-library.tsx b/tests/fixtures/agent-library.tsx index bb22719857..307b4f5f96 100644 --- a/tests/fixtures/agent-library.tsx +++ b/tests/fixtures/agent-library.tsx @@ -314,6 +314,9 @@ const agents = { const disconnected = { status: "disconnected", generation: 0, + ...(new URLSearchParams(location.search).has("reorder") + ? { scope: "https://relay.example:fixture-owner" } + : {}), ...(teamImport ? { viewer: "owner",