From 227917d2c24610eb2ae26ff4ed66f5828d3b34db Mon Sep 17 00:00:00 2001 From: Zach Marley Date: Wed, 28 Oct 2026 13:06:34 -0400 Subject: [PATCH 1/4] feat(agents): reorder agent cards with hold and drag Keep card order on this device per account and community, with live drag previews and menu actions for keyboard users. Co-authored-by: Codex Signed-off-by: Zach Marley --- src/bundled/agents/AgentCard.module.css | 22 +++ src/bundled/agents/AgentInventoryDrag.tsx | 198 ++++++++++++++++++++++ src/bundled/agents/AgentsPage.test.tsx | 72 +++++++- src/bundled/agents/AgentsPage.tsx | 98 ++++++++++- src/bundled/agents/agent-order.ts | 60 +++++++ tests/browser/agent-reorder.spec.mjs | 154 +++++++++++++++++ tests/fixtures/agent-library.tsx | 3 + 7 files changed, 598 insertions(+), 9 deletions(-) create mode 100644 src/bundled/agents/AgentInventoryDrag.tsx create mode 100644 src/bundled/agents/agent-order.ts create mode 100644 tests/browser/agent-reorder.spec.mjs 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..b28c8c12ae 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,72 @@ it("offers Make main agent on a stand-in for a missing chosen agent", async () = ).queryByRole("menuitem", { name: "Make main agent" }), ).toBeNull(); }); + +it("moves cards from their actions without remounting neighbours, and restores the scoped order", 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).getByRole("menuitem", { name: "Move earlier" }), + ).toHaveAttribute("aria-disabled", "true"); + act(() => view.show([ada, bo, nova])); + expect(menu).toBeInTheDocument(); + await user.click(within(menu).getByRole("menuitem", { name: "Move later" })); + 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"]); +}); + +it("keeps the old order on a failed save, then lets the same move retry", async () => { + const user = userEvent.setup(); + const scope = "https://relay.example:owner"; + writeView(scope, "agents:order", { malformed: true }); + const view = setup([agent("Ada", 1), agent("Bo", 2)], { scope }); + const order = () => + screen + .getAllByRole("gridcell") + .map((card) => card.getAttribute("aria-label")); + expect(order()).toEqual(["Ada", "Bo"]); + const open = async () => { + await user.click(screen.getByRole("button", { name: "Actions for Ada" })); + return within( + await screen.findByRole("menu", { name: "Actions for Ada" }), + ).getByRole("menuitem", { name: "Move later" }); + }; + const move = await open(); + const storage = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + try { + await user.click(move); + expect(await screen.findByRole("alert")).toHaveTextContent( + "order couldn't be saved", + ); + expect(order()).toEqual(["Ada", "Bo"]); + } finally { + storage.mockRestore(); + } + await user.click(await open()); + expect(order()).toEqual(["Bo", "Ada"]); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + act(() => view.show([agent("Bo", 2), agent("New", 3)])); + expect(order()).toEqual(["Bo", "New"]); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index f1a3337203..c207e098e4 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 { moveAgent, 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 +834,12 @@ function Inventory({ defaultChoices, onMakeDefault, session, -}: InventoryProps) { + onReorder, + dragging, +}: InventoryProps & { + dragging: boolean; + onReorder(agents: readonly Agent[]): void; +}) { const grid = useRef(null); const index = agents.findIndex((agent) => agent.pubkey === selected); const columns = () => @@ -795,6 +847,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 +886,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 +901,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. -
+ + onReorder( + moveAgent(agents, position, position + delta), + ), + } + : undefined + } service={service} session={session} type={type} @@ -905,7 +972,7 @@ function Inventory({ icon={} detail={summaryOf(agent, type)} /> -
+ ); })}
@@ -922,6 +989,7 @@ function InventoryCard({ onBuild, onRemoved, main, + reorder, ...props }: Omit, "agent"> & { agent: Agent; @@ -930,6 +998,9 @@ function InventoryCard({ type: RegisteredAgentType | undefined; onBuild(): void; onRemoved(): void; + reorder?: + | { first: boolean; last: boolean; move(delta: number): void } + | undefined; /** Whether it is the community's main agent and can be made it. */ main?: | { isMain: boolean; pinned: boolean; available: boolean; onMake(): void } @@ -968,6 +1039,17 @@ function InventoryCard({ ))} {main && } + {reorder && ( + <> + reorder.move(-1)}> + Move earlier + + reorder.move(1)}> + Move later + + + + )} {editable && Open settings} {editable && session && type?.exportShared && ( setSharing(true)}>Share diff --git a/src/bundled/agents/agent-order.ts b/src/bundled/agents/agent-order.ts new file mode 100644 index 0000000000..3667983f6e --- /dev/null +++ b/src/bundled/agents/agent-order.ts @@ -0,0 +1,60 @@ +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 ranks = new Map( + (Array.isArray(saved) ? saved : []).flatMap((key, index) => + typeof key === "string" && /^[0-9a-f]{64}$/.test(key) + ? [[key, index] as const] + : [], + ), + ); + const ordered = [...agents].sort( + (a, b) => + (ranks.get(a.pubkey) ?? Infinity) - (ranks.get(b.pubkey) ?? Infinity), + ); + const save = (next: readonly Agent[]) => { + const saved = + !!scope && + writeView( + scope, + KEY, + next.map((agent) => agent.pubkey), + ); + 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..353d2b0a0e --- /dev/null +++ b/tests/browser/agent-reorder.spec.mjs @@ -0,0 +1,154 @@ +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); + 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"); + // Let both the overlay and dnd-kit's click guard finish before mouseup. + 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", From 614eecf1ecf6989a019cd29c2f697264fe229367 Mon Sep 17 00:00:00 2001 From: Zach Marley Date: Wed, 28 Oct 2026 13:16:56 -0400 Subject: [PATCH 2/4] refactor(agents): keep card reordering drag-only Remove menu reordering actions as requested in product review; retain persistence and save-recovery coverage at the existing ordering hook. Co-authored-by: Codex Signed-off-by: Zach Marley --- src/bundled/agents/AgentsPage.test.tsx | 50 ++++--------------------- src/bundled/agents/AgentsPage.tsx | 32 +--------------- src/bundled/agents/agent-order.test.tsx | 43 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 73 deletions(-) create mode 100644 src/bundled/agents/agent-order.test.tsx diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index b28c8c12ae..92e2a4029b 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -3409,7 +3409,7 @@ it("offers Make main agent on a stand-in for a missing chosen agent", async () = ).toBeNull(); }); -it("moves cards from their actions without remounting neighbours, and restores the scoped order", async () => { +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); @@ -3423,11 +3423,15 @@ it("moves cards from their actions without remounting neighbours, and restores t await user.click(screen.getByRole("button", { name: "Actions for Ada" })); const menu = await screen.findByRole("menu", { name: "Actions for Ada" }); expect( - within(menu).getByRole("menuitem", { name: "Move earlier" }), - ).toHaveAttribute("aria-disabled", "true"); + 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.click(within(menu).getByRole("menuitem", { name: "Move later" })); + 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}"); @@ -3439,41 +3443,3 @@ it("moves cards from their actions without remounting neighbours, and restores t setup([ada, bo], { scope: "https://other.example:owner" }); expect(order()).toEqual(["Ada", "Bo"]); }); - -it("keeps the old order on a failed save, then lets the same move retry", async () => { - const user = userEvent.setup(); - const scope = "https://relay.example:owner"; - writeView(scope, "agents:order", { malformed: true }); - const view = setup([agent("Ada", 1), agent("Bo", 2)], { scope }); - const order = () => - screen - .getAllByRole("gridcell") - .map((card) => card.getAttribute("aria-label")); - expect(order()).toEqual(["Ada", "Bo"]); - const open = async () => { - await user.click(screen.getByRole("button", { name: "Actions for Ada" })); - return within( - await screen.findByRole("menu", { name: "Actions for Ada" }), - ).getByRole("menuitem", { name: "Move later" }); - }; - const move = await open(); - const storage = vi - .spyOn(Storage.prototype, "setItem") - .mockImplementation(() => { - throw new Error("Storage unavailable"); - }); - try { - await user.click(move); - expect(await screen.findByRole("alert")).toHaveTextContent( - "order couldn't be saved", - ); - expect(order()).toEqual(["Ada", "Bo"]); - } finally { - storage.mockRestore(); - } - await user.click(await open()); - expect(order()).toEqual(["Bo", "Ada"]); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - act(() => view.show([agent("Bo", 2), agent("New", 3)])); - expect(order()).toEqual(["Bo", "New"]); -}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index c207e098e4..4832c00cd6 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -34,7 +34,7 @@ import { import { AgentShareDialog } from "./AgentShareDialog"; import { AgentCard, AgentPortrait, TeamCard } from "./AgentCard"; import { AgentInventoryDrag, DraggableAgentRow } from "./AgentInventoryDrag"; -import { moveAgent, useAgentOrder } from "./agent-order"; +import { useAgentOrder } from "./agent-order"; import { AgentCreateDialog, TypeIcon } from "./AgentCreateDialog"; import { NewAgentTile } from "./NewAgentTile"; import type { ChannelChoice } from "../../features/agents/attention"; @@ -807,7 +807,6 @@ function Inventory({ scope, agents, typeOf, ...props }: InventoryProps) { agents={preview} dragging={dragging} typeOf={typeOf} - onReorder={save} /> )} @@ -834,11 +833,9 @@ function InventoryGrid({ defaultChoices, onMakeDefault, session, - onReorder, dragging, }: InventoryProps & { dragging: boolean; - onReorder(agents: readonly Agent[]): void; }) { const grid = useRef(null); const index = agents.findIndex((agent) => agent.pubkey === selected); @@ -926,18 +923,6 @@ function InventoryGrid({ } : undefined } - reorder={ - scope - ? { - first: position === 0, - last: position === agents.length - 1, - move: (delta) => - onReorder( - moveAgent(agents, position, position + delta), - ), - } - : undefined - } service={service} session={session} type={type} @@ -989,7 +974,6 @@ function InventoryCard({ onBuild, onRemoved, main, - reorder, ...props }: Omit, "agent"> & { agent: Agent; @@ -998,9 +982,6 @@ function InventoryCard({ type: RegisteredAgentType | undefined; onBuild(): void; onRemoved(): void; - reorder?: - | { first: boolean; last: boolean; move(delta: number): void } - | undefined; /** Whether it is the community's main agent and can be made it. */ main?: | { isMain: boolean; pinned: boolean; available: boolean; onMake(): void } @@ -1039,17 +1020,6 @@ function InventoryCard({ ))} {main && } - {reorder && ( - <> - reorder.move(-1)}> - Move earlier - - reorder.move(1)}> - Move later - - - - )} {editable && Open settings} {editable && session && type?.exportShared && ( setSharing(true)}>Share diff --git a/src/bundled/agents/agent-order.test.tsx b/src/bundled/agents/agent-order.test.tsx new file mode 100644 index 0000000000..a6bb63df9e --- /dev/null +++ b/src/bundled/agents/agent-order.test.tsx @@ -0,0 +1,43 @@ +// @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 { 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]); +}); From 96e6ab3b9d3b3d2a9d38c50920bd7f67c437c9c5 Mon Sep 17 00:00:00 2001 From: Zach Marley Date: Wed, 28 Oct 2026 13:37:46 -0400 Subject: [PATCH 3/4] test(agents): wait for cancelled drag overlay before next gesture The Web Animations drop transition follows real browser time, not the test clock. Observe its unmount before sending another pointerdown. Co-authored-by: Codex Signed-off-by: Zach Marley --- tests/browser/agent-reorder.spec.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/browser/agent-reorder.spec.mjs b/tests/browser/agent-reorder.spec.mjs index 353d2b0a0e..e3f013326c 100644 --- a/tests/browser/agent-reorder.spec.mjs +++ b/tests/browser/agent-reorder.spec.mjs @@ -40,6 +40,10 @@ test("holding an agent previews a move, cancels cleanly, and saves only on drop" 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, @@ -106,7 +110,7 @@ test("holding an agent previews a move, cancels cleanly, and saves only on drop" await expect(page.locator("[data-dragging]")).toHaveCount(0); await expect(panel).toBeVisible(); await expect(ada).toHaveAttribute("aria-selected", "true"); - // Let both the overlay and dnd-kit's click guard finish before mouseup. + // 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 }), From 4a805512bb21242ced5c6abb6d7247beb6a4965c Mon Sep 17 00:00:00 2001 From: Zach Marley Date: Wed, 28 Oct 2026 15:02:54 -0400 Subject: [PATCH 4/4] fix(agents): retain saved order for temporarily absent agents Rewrite only the visible subset's saved slots so reordering local agents during startup or reconnect preserves returning agents' positions. Co-authored-by: Codex Signed-off-by: Zach Marley --- src/bundled/agents/agent-order.test.tsx | 49 ++++++++++++++++++++++++- src/bundled/agents/agent-order.ts | 31 +++++++++------- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/bundled/agents/agent-order.test.tsx b/src/bundled/agents/agent-order.test.tsx index a6bb63df9e..b4e66ef127 100644 --- a/src/bundled/agents/agent-order.test.tsx +++ b/src/bundled/agents/agent-order.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import type { Agent } from "../../features/agents/service"; -import { writeView } from "../../shared/view-state"; +import { readView, writeView } from "../../shared/view-state"; import { moveAgent, useAgentOrder } from "./agent-order"; afterEach(() => { @@ -41,3 +41,50 @@ it("keeps the saved order after a failed save, retries, and reconciles removed a 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 index 3667983f6e..2cf8b69851 100644 --- a/src/bundled/agents/agent-order.ts +++ b/src/bundled/agents/agent-order.ts @@ -23,25 +23,30 @@ export function useAgentOrder( ); const [error, setError] = useState(""); const saved = scope ? readView(scope, KEY, []) : []; - const ranks = new Map( - (Array.isArray(saved) ? saved : []).flatMap((key, index) => - typeof key === "string" && /^[0-9a-f]{64}$/.test(key) - ? [[key, index] as const] - : [], + 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 saved = - !!scope && - writeView( - scope, - KEY, - next.map((agent) => agent.pubkey), - ); + 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 ? ""