diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e26420a..9973a9d 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -74,7 +74,8 @@ old container. - An action's container is its **focus** — always exactly one (PROG-102). The arc-in-focus invariant is API-enforced (SQLite can't express it cheaply). - Actions move between **focuses**. A move re-keys the action from the target's - sequence, clears its arc, and writes the old key to `action_key_aliases` as a + sequence, clears its arc — or lands it in a caller-named arc **of the target + focus** (PROG-118) — and writes the old key to `action_key_aliases` as a permanent redirect (D18, D24). A move to the action's current focus is a no-op. - Action keys are **derived, never stored**: `focus.keyPrefix + "-" + action.number`. Renaming a prefix re-keys everything consistently; alias @@ -230,7 +231,7 @@ mandatory — renew it before it lapses (an expired file is worse than none). | `GET /api/snapshot` | The load-everything payload (`SnapshotPayload`): `me` (the signed-in user, PROG-34), users, workspaces, focuses, arcs, actions, tags, actionTags, actionKeyAliases — eight independent reads run with `Promise.all` (not a `db.batch`/transaction, which 500'd on production D1; D31). Comments/activity are deliberately excluded (D20). | | `POST /api/actions` | `{ title, focusId, arcId?, parentActionId?, description?, status?, priority?, estimate?, dueDate?, tagIds? }` → 201 `{ action }`. `dueDate` is `YYYY-MM-DD` or null, validated (impossible dates rejected). `parentActionId` must be an existing action in the same focus (PROG-124). `tagIds` (PROG-89b) links existing tags at birth — every id must exist or the whole create 400s. Number allocated by atomic increment of the focus sequence; gaps from failed creates are harmless (D24). A board `rank` is auto-assigned, appended after the current last action (D44). | | `PATCH /api/actions/:id` | Any of `title, description, status, priority, estimate, arcId, parentActionId, dueDate, rank` — validated per field; arc and parent must be same-focus; `parentActionId` reparent is acyclic and not self (PROG-124); `dueDate`/`arcId`/`parentActionId` accept null to clear. `rank` is a fractional-index board key the client computes from the drop site's neighbors (D44). A status change atomically appends a `status_changed` activity row and maintains `completedAt`. | -| `POST /api/actions/:id/move` | `{ focusId }` — the focus is the sole container (PROG-102). Re-keys from the target focus, clears arc, detaches steps, writes the alias, logs `moved`. 400 if `focusId` is the action's current focus (no-op). | +| `POST /api/actions/:id/move` | `{ focusId, arcId?, rank? }` — the focus is the sole container (PROG-102). Re-keys from the target focus, clears arc, detaches steps, writes the alias, logs `moved`. `arcId`/`rank` (PROG-118, the Outline's cross-focus drag) name a landing spot: the arc must belong to the **target** focus, `rank` is a validated fractional key. 400 if `focusId` is the action's current focus (no-op). | | `GET /api/actions/:id/timeline` | `{ comments, activity, pullRequests, commits }`, each ordered by `createdAt`. | | `GET /api/actions/:key/bundle` | Looked up by **key** (alias-aware), not id. Returns `text/markdown` — a deterministic context "work order": action fields + tags, lineage with descriptions (focus incl. optional `gitUrl` → arc, where the arc description carries the "why"), comments, an **Images** list (absolute URLs of every image referenced in the description/comments, so a bearer-authed agent can fetch them — PROG-42), linked PRs/commits, then a stable report-back preamble — **branch off fresh `origin/main`, never another feature branch, and PR with `--base main`** (PROG-95), branch/key auto-linking + status flow, plus a **Committing & PRs** block that embeds a local, key-aware copy of the owner's smart-commit conventions (logical chunks, secret-scan, `type(scope): KEY subject`, no AI attribution) so a handed-off agent commits to the owner's rules (PROG-62). A retired key resolves and renders the current canonical key. 400 malformed key, 404 unknown. Rendered by `src/worker/bundle.ts` (`renderBundle`); shared foundation for the agent surfaces (SPEC §11.1, D33). | | `GET /api/arcs/:id/bundle` | Looked up by **id** (the arc page has it). Returns `text/markdown` — the **arc** work order: a single prompt covering **every open action** in the arc (`done`/`canceled` dropped via `isOpenStatus`), each rendered like the action bundle (fields, description, comments, Images, linked PRs/commits) minus its per-action footer, with focus/arc lineage (incl. the focus's optional `gitUrl`) stated once. Ends in **combined-PR** orchestration — fan the actions to sub-agents, share one branch, land **one PR naming every key** — plus the same smart-commit block (keyed per-commit). Deterministic (status-then-number sort). 404 unknown arc. Rendered by `renderArcBundle` in `src/worker/bundle.ts`. | @@ -430,13 +431,32 @@ so old bookmarks keep working. (`rankForReorder`, `src/client/outlineReorder.ts`) — the **same** fractional key the board orders by, so a drag here moves the card on the board and vice-versa. Only the handle starts a drag (the title input stays editable); - reparenting stays on Tab/Shift+Tab (PROG-86). **Container sections reorder + reparenting stays on Tab/Shift+Tab (PROG-86). Dropped **outside** its own + sibling group, the action **moves** there instead (PROG-118): one page-wide + `DndContext` covers every row and section, so a drop onto a row in another + group joins that group where released (same-focus: one optimistic + `PATCH { arcId, parentActionId, rank }`; `rankForInsert` slots it above or + below the hovered row by the pointer-past-middle rule the board uses), a + drop onto an arc section/header appends to that arc's top level, and a drop + into another focus's section is a real **move** (re-key + alias, steps + detach) landing top-level at the drop spot via + `POST /api/actions/:id/move { focusId, arcId?, rank? }`. A drop into the + action's own subtree is refused client-side (`inSubtreeOf`, + `src/client/outlineTree.ts`). **Container sections reorder the same way** (PROG-87): arc sections within a focus, and focus sections at workspace scope, each drag as a whole block from the handle in their - header. - A held section is carried by a floating `DragOverlay` preview (capped rows, - shadow — the board's pattern) while the in-list source dims and the rest of - the outline goes pointer-inert, so nothing hover-highlights under the drag. + header — sections never change parents by drag; only actions move. + Anything held — a section **or an action row** — is carried by a floating + `DragOverlay` preview (capped rows, shadow; rows add the board card's slight + rotation) while the in-list source dims to a ghost and the rest of the + outline goes pointer-inert, so nothing hover-highlights under the drag. + While a held row hovers a *different* sibling group, an `onDragOver` preview + re-homes it there in the rendered list (the board's PROG-59 pattern), so the + target group — in any arc or focus — visibly opens the landing slot; the + drop then commits exactly what the preview shows. On release the overlay + glides into the committed slot (default drop tween, ~180ms) — safe from the + old fly-back because PROG-119 made optimistic writes notify synchronously, + so the destination is already re-rendered when the tween measures it. Container ranks sort `(rank, name)` — alphabetical until first reordered; a drag in a still-tied group renumbers the group, after which each drag is one write (`containerReorderRanks`, `src/client/containerReorder.ts`). The order diff --git a/docs/decisions/PROG-118.md b/docs/decisions/PROG-118.md new file mode 100644 index 0000000..f7c1c96 --- /dev/null +++ b/docs/decisions/PROG-118.md @@ -0,0 +1,78 @@ +### PROG-118 — Outline drag moves actions between arcs and focuses + +PROG-86 shipped drag-to-reorder but deliberately made a drop outside the row's +own sibling group a **no-op**, deferring drag-to-reparent as "a separate, +larger interaction". PROG-118 is that interaction: on the Outline, dragging an +action and releasing it in another arc, at the loose level, in another step +group, or in another focus's section now **moves** it there. *Decisions +within:* + +1. **One page-wide `DndContext`** replaces the nested per-focus contexts (and + the workspace-scope outer context for focus sections). Nested contexts made + cross-section drags structurally impossible — the inner context swallowed + the drag before the outer one could see the target. A single context with + one `onDragEnd` that branches on what was picked up (focus / arc / action) + keeps the PROG-87 section-reorder semantics intact while letting action + rows travel anywhere on the page. +2. **"It lands where you dropped it."** A drop onto a row in another group + adopts that row's `(arcId, parentActionId)` and slots above/below it by the + board's pointer-past-middle rule (`rankForInsert`); a drop onto an arc + section (header or empty body) appends to that arc's top level; a drop onto + a focus section lands loose. One rule, no modes. +3. **Same-focus moves are one optimistic `PATCH`** (`arcId` + + `parentActionId` + `rank`) — the existing update path, no new endpoint. + **Cross-focus drops reuse the real move** (`POST /api/actions/:id/move`: + re-key, alias, steps detach — PROG-102/PROG-124), now with optional + `arcId`/`rank` so the action lands in the arc and position it was released + over instead of always loose-at-default. The arc must belong to the target + focus (server-validated); both fields are optional so the palette's + focus-only move is unchanged. +4. **Cross-focus drops land top-level.** A moved action can't keep or gain a + parent across focuses (same-focus invariant), so a drop over a step slots + relative to that step's top-level root ancestor. Cross-focus + drag-to-reparent (into a specific step group) is out of scope. +5. **Cycle guard client-side.** A drop into the action's own subtree is + refused before the write (`inSubtreeOf`) — the server would reject it + anyway, but skipping the doomed request avoids optimistic-rollback churn. +6. **Arcs and focuses still only reorder.** "All actions should be draggable + between Arcs and Focuses" — actions, not containers; an arc dropped outside + its focus stays put. + +Verified by unit tests (`rankForInsert`, `inSubtreeOf`, `moveAction` +optimistic/rollback with arc+rank) and two new Playwright specs +(`e2e/outline-move.spec.ts`: arc→arc drag, and a cross-focus drag asserting +re-key + alias). The pre-existing `e2e/outline-container-reorder.spec.ts` was +also repaired in passing — its selectors still targeted the pre-PROG-111 grip +buttons, so it had been red since the handle consolidation. + +### PROG-118b — held rows float, foreign groups open a slot, drops settle + +The first cut moved rows correctly but read as inert: the grabbed row only +translated in place, foreign groups never showed where it would land, and the +release popped. Owner feedback asked for the board's feel. *Decisions within:* + +1. **DragOverlay for held rows** — the same floating-card pattern as board + cards and the PROG-87 section previews: bullet + title (+ capped step + subtree), slight rotation and shadow, width capped to `max-w-md` so a + full-width row reads as a card in hand. The in-list source dims to a ghost + but **keeps its sorting translate** (exactly `BoardCard`): an untransformed + ghost fights the strategy's neighbour displacement and overlaps rows. +2. **Live cross-group preview** (`onDragOver`, the board's PROG-59 pattern): + when the hovered target resolves to a different sibling group, the row is + re-homed there in the rendered list (`previewedActions` patches the active + row's `focusId`/`arcId`/`parentActionId`/`rank`), so the target group — + across arcs and focuses — genuinely opens the landing slot. Within one + group the preview stays out (dnd-kit's same-context transforms already + animate the gap; re-rendering against them fights). The **drop commits the + preview**, not a re-resolution of `over` — after a preview, `over` is + usually the active row itself (the PROG-59 lesson); `over` only fine-tunes + the position within the previewed group. Requires + `MeasuringStrategy.Always` since the preview moves real layout mid-drag. +3. **Real drop animation** instead of `dropAnimation={null}`: the ~180ms + default tween glides the overlay into the committed slot with the shadow + easing off. The null was PROG-43's workaround for the tween flying to the + *stale* slot; PROG-119's synchronous store notification means the + destination row is already re-rendered when the tween measures it, so the + workaround (kept on the board, which still has its local mirror) is + unnecessary here. Verified frame-by-frame: the landed row is stationary at + its final position from frame 0 after mouseup. diff --git a/e2e/outline-container-reorder.spec.ts b/e2e/outline-container-reorder.spec.ts index 80a5d3a..8228806 100644 --- a/e2e/outline-container-reorder.spec.ts +++ b/e2e/outline-container-reorder.spec.ts @@ -29,12 +29,16 @@ async function yOrder(page: Page, names: string[]): Promise { return boxes.sort((a, b) => a.y - b.y).map((b) => b.name); } -// Press a grip and clear the 4px drag-activation threshold, glide to the -// target y in small steps so dnd-kit tracks it, release. Mid-drag, the held -// section must be carried by a floating DragOverlay preview (the board's -// pattern — instant pickup + depth) and it must clear on release. -async function dragGrip(page: Page, gripLabel: string, toY: number) { - const grip = (await page.getByRole("button", { name: gripLabel }).boundingBox())!; +// Press a section's bullet handle (PROG-111: an anchor that dnd-kit's sortable +// attributes expose as role=button, named "Open — drag to reorder"), +// clear the 4px drag-activation threshold, glide to the target y in small +// steps so dnd-kit tracks it, release. Mid-drag, the held section must be +// carried by a floating DragOverlay preview (the board's pattern — instant +// pickup + depth) and it must clear on release. +async function dragGrip(page: Page, name: string, toY: number) { + const grip = (await page + .getByRole("button", { name: `Open ${name} — drag to reorder`, exact: true }) + .boundingBox())!; const cx = grip.x + grip.width / 2; const cy = grip.y + grip.height / 2; await page.mouse.move(cx, cy); @@ -94,7 +98,7 @@ test("arcs list alphabetically by default and drag-reorder within a focus (PROG- const alphaBox = (await page .getByRole("link", { name: "Alpha e2e", exact: true }) .boundingBox())!; - await dragGrip(page, "Reorder Gamma e2e", alphaBox.y - 10); + await dragGrip(page, "Gamma e2e", alphaBox.y - 10); await expect .poll(() => yOrder(page, ["Alpha e2e", "Beta e2e", "Gamma e2e"])) @@ -154,7 +158,7 @@ test("focuses drag-reorder at workspace scope (PROG-87)", async ({ page }) => { const appleBox = (await page .getByRole("link", { name: "Apple e2e", exact: true }) .boundingBox())!; - await dragGrip(page, "Reorder Cherry e2e", appleBox.y - 20); + await dragGrip(page, "Cherry e2e", appleBox.y - 20); await expect .poll(() => yOrder(page, ["Apple e2e", "Cherry e2e"])) diff --git a/e2e/outline-move.spec.ts b/e2e/outline-move.spec.ts new file mode 100644 index 0000000..03e4e01 --- /dev/null +++ b/e2e/outline-move.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from "@playwright/test"; +import { signInAsOwner } from "./auth"; + +// Drag-to-MOVE on the Outline (PROG-118), in a real browser — the page-wide +// DndContext lets an action row be dropped outside its own sibling group: into +// another arc (one optimistic PATCH) or into another focus entirely (a real +// move: re-key + alias, via POST /api/actions/:id/move with the landing arc and +// rank). The rank/guard math is unit-tested (outlineReorder/outlineTree); these +// prove the end-to-end wiring with genuine pointer events, and that the result +// is server-side (it survives a reload). +// +// Each test creates its OWN containers/actions via the API (unique names and +// prefixes), so it is independent of the ambient dev DB and of other specs; it +// archives the containers again at the end to keep the ambient views clean. + +const tag = () => Math.random().toString(36).slice(2, 8); +const prefix = (lead: string) => + `${lead}${tag() + .toUpperCase() + .replaceAll(/[^A-Z]/g, "Z") + .padEnd(4, "X") + .slice(0, 4)}`; + +async function post(page: Page, path: string, data: Record): Promise { + const res = await page.request.post(path, { data }); + expect(res.ok(), `${path} → ${res.status()}`).toBe(true); + return (await res.json()) as T; +} + +type Container = { id: string }; +type Action = { id: string; number: number; arcId: string | null; focusId: string }; + +// A row's bullet handle (PROG-111): an anchor that dnd-kit's sortable +// attributes expose as role=button, named "Open — drag to reorder". +const handleOf = (page: Page, key: string) => + page.getByRole("button", { name: `Open ${key} — drag to reorder`, exact: true }); + +// Press an action row's bullet handle, clear the 4px activation threshold, +// glide to the target point in small steps so dnd-kit tracks it, and release. +// Mid-drag, the held row must be carried by a floating DragOverlay card +// (constant grabbed-it feedback that persists across arc/focus sections — +// PROG-118 polish), and it must clear once the drop settles. +async function dragActionTo(page: Page, actionKey: string, toX: number, toY: number) { + const handle = (await handleOf(page, actionKey).boundingBox())!; + const cx = handle.x + handle.width / 2; + const cy = handle.y + handle.height / 2; + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx, cy + 6); + await expect(page.locator("[data-drag-overlay]")).toHaveCount(1); + const steps = 20; + for (let s = 1; s <= steps; s++) { + await page.mouse.move(cx + ((toX - cx) * s) / steps, cy + 6 + ((toY - cy - 6) * s) / steps); + } + await expect(page.locator("[data-drag-overlay]")).toHaveCount(1); + await page.mouse.up(); + // The drop animation (~180ms) removes the overlay when the card lands. + await expect(page.locator("[data-drag-overlay]")).toHaveCount(0, { timeout: 1000 }); +} + +test.beforeEach(async ({ context }) => { + await signInAsOwner(context); +}); + +test("an action drags from one arc into another (PROG-118)", async ({ page }) => { + await page.goto("/outline"); + const { workspaces } = await (await page.request.get("/api/snapshot")).json(); + const focus = ( + await post<{ container: Container & { keyPrefix: string } }>(page, "/api/focuses", { + name: `E2E move arcs ${tag()}`, + keyPrefix: prefix("Q"), + workspaceId: workspaces[0].id, + }) + ).container; + const src = ( + await post<{ container: Container }>(page, "/api/arcs", { + name: "Src arc e2e", + focusId: focus.id, + }) + ).container; + const dst = ( + await post<{ container: Container }>(page, "/api/arcs", { + name: "Dst arc e2e", + focusId: focus.id, + }) + ).container; + const mover = ( + await post<{ action: Action; key: string }>(page, "/api/actions", { + title: "Mover e2e", + focusId: focus.id, + arcId: src.id, + }) + ).action; + const anchor = ( + await post<{ action: Action }>(page, "/api/actions", { + title: "Anchor e2e", + focusId: focus.id, + arcId: dst.id, + }) + ).action; + const moverKey = `${focus.keyPrefix}-${mover.number}`; + const anchorKey = `${focus.keyPrefix}-${anchor.number}`; + + await page.goto(`/outline?focus=${focus.id}`); + await handleOf(page, moverKey).waitFor(); + + // Drop Mover just below Anchor's row, inside the Dst arc section. + const anchorBox = (await handleOf(page, anchorKey).boundingBox())!; + await dragActionTo(page, moverKey, anchorBox.x + 60, anchorBox.y + anchorBox.height * 0.9); + + // The move is one optimistic PATCH { arcId, rank } — poll the server for it. + await expect + .poll(async () => { + const ws = await (await page.request.get("/api/snapshot")).json(); + return ws.actions.find((i: Action) => i.id === mover.id)?.arcId; + }) + .toBe(dst.id); + // Anchor stayed put; Mover slotted below it (drop was past its middle). + expect( + (await (await page.request.get("/api/snapshot")).json()).actions.find( + (i: Action) => i.id === anchor.id, + ).arcId, + ).toBe(dst.id); + + await page.request.patch(`/api/focuses/${focus.id}`, { data: { archived: true } }); +}); + +test("an action drags into another focus and is re-keyed (PROG-118)", async ({ page }) => { + await page.goto("/outline"); + const workspace = ( + await post<{ container: Container }>(page, "/api/workspaces", { + name: `E2E move focus ${tag()}`, + }) + ).container; + const mkFocus = async (name: string, keyPrefix: string) => + ( + await post<{ container: Container & { keyPrefix: string } }>(page, "/api/focuses", { + name, + keyPrefix, + workspaceId: workspace.id, + }) + ).container; + const from = await mkFocus("From focus e2e", prefix("F")); + const to = await mkFocus("To focus e2e", prefix("T")); + const mover = ( + await post<{ action: Action }>(page, "/api/actions", { + title: "Migrant e2e", + focusId: from.id, + }) + ).action; + const anchor = ( + await post<{ action: Action }>(page, "/api/actions", { + title: "Resident e2e", + focusId: to.id, + }) + ).action; + + const moverKey = `${from.keyPrefix}-${mover.number}`; + const anchorKey = `${to.keyPrefix}-${anchor.number}`; + + await page.goto(`/outline?workspace=${workspace.id}`); + await handleOf(page, moverKey).waitFor(); + + // Drop Migrant just below Resident's row, inside the other focus's section. + const anchorBox = (await handleOf(page, anchorKey).boundingBox())!; + await dragActionTo(page, moverKey, anchorBox.x + 60, anchorBox.y + anchorBox.height * 0.9); + + // A cross-focus drop is a real move: new focus, re-keyed from the target's + // sequence (after Resident took number 1), old key retired to an alias. + await expect + .poll(async () => { + const ws = await (await page.request.get("/api/snapshot")).json(); + return ws.actions.find((i: Action) => i.id === mover.id)?.focusId; + }) + .toBe(to.id); + const ws = await (await page.request.get("/api/snapshot")).json(); + const moved = ws.actions.find((i: Action) => i.id === mover.id) as Action; + expect(moved.number).toBe(anchor.number + 1); + expect( + ws.actionKeyAliases.some( + (a: { key: string; actionId: string }) => + a.key === `${from.keyPrefix}-${mover.number}` && a.actionId === mover.id, + ), + ).toBe(true); + + // And the row renders under its NEW key after a full reload. + await page.reload(); + await handleOf(page, `${to.keyPrefix}-${moved.number}`).waitFor(); + + for (const p of [from, to]) + await page.request.patch(`/api/focuses/${p.id}`, { data: { archived: true } }); + await page.request.patch(`/api/workspaces/${workspace.id}`, { data: { archived: true } }); +}); diff --git a/src/client/outlineReorder.test.ts b/src/client/outlineReorder.test.ts index 96dd246..ba34dc7 100644 --- a/src/client/outlineReorder.test.ts +++ b/src/client/outlineReorder.test.ts @@ -2,7 +2,7 @@ // Ranks are single decimal digits ("1".."4"), a valid subset of the rank // alphabet that sorts the same way as the fractional keys minted in production. import { describe, expect, it } from "bun:test"; -import { rankForReorder } from "./outlineReorder"; +import { rankForInsert, rankForReorder } from "./outlineReorder"; const RANKS: Record = { a: "1", b: "2", c: "3", d: "4" }; const rankOf = (id: string) => RANKS[id]!; @@ -49,3 +49,29 @@ describe("rankForReorder", () => { expect(rankForReorder(group, rankOf, "zzz", "a")).toBeNull(); }); }); + +describe("rankForInsert (cross-group drop, PROG-118)", () => { + it("lands above the hovered member when the pointer is above its middle", () => { + between(rankForInsert(group, rankOf, "b", false), "1", "2"); + }); + + it("lands below the hovered member when the pointer is past its middle", () => { + between(rankForInsert(group, rankOf, "b", true), "2", "3"); + }); + + it("lands at the very top (open start)", () => { + between(rankForInsert(group, rankOf, "a", false), null, "1"); + }); + + it("lands at the very bottom (open end)", () => { + between(rankForInsert(group, rankOf, "d", true), "4", null); + }); + + it("appends when dropped over the group itself (overId not a member)", () => { + between(rankForInsert(group, rankOf, "the-arc-section", false), "4", null); + }); + + it("mints a first rank for an empty group", () => { + between(rankForInsert([], rankOf, "the-arc-section", false), null, null); + }); +}); diff --git a/src/client/outlineReorder.ts b/src/client/outlineReorder.ts index 8095b79..15da121 100644 --- a/src/client/outlineReorder.ts +++ b/src/client/outlineReorder.ts @@ -40,3 +40,25 @@ export function rankForReorder( // and rankBetween never throws. return rankBetween(prevId ? rankOf(prevId) : null, nextId ? rankOf(nextId) : null); } + +/** + * Rank for `active` landing in a sibling group it is NOT currently part of — a + * cross-group drop (PROG-118: into another arc, the loose level, or another + * focus). `groupIds` is the target group in rendered order, without the active + * id. Dropped over a member, the action slots before it — or after when + * `below` (pointer past the member's vertical middle, the board's cross-column + * rule). Dropped over the group itself (its section header / empty body, + * `overId` not in the group), it appends to the end. + */ +export function rankForInsert( + groupIds: string[], + rankOf: (id: string) => string, + overId: string, + below: boolean, +): string { + const overIndex = groupIds.indexOf(overId); + const insertAt = overIndex < 0 ? groupIds.length : overIndex + (below ? 1 : 0); + const prevId = insertAt > 0 ? groupIds[insertAt - 1]! : null; + const nextId = insertAt < groupIds.length ? (groupIds[insertAt] ?? null) : null; + return rankBetween(prevId ? rankOf(prevId) : null, nextId ? rankOf(nextId) : null); +} diff --git a/src/client/outlineTree.test.ts b/src/client/outlineTree.test.ts index 69bf3e2..21de463 100644 --- a/src/client/outlineTree.test.ts +++ b/src/client/outlineTree.test.ts @@ -4,7 +4,13 @@ // parent's arc) and the hide-done subtree-drop semantics. import { describe, expect, it } from "bun:test"; import type { WireAction } from "../shared/types"; -import { buildForest, byRankThenNumber, siblingsOf, type OutlineNode } from "./outlineTree"; +import { + buildForest, + byRankThenNumber, + inSubtreeOf, + siblingsOf, + type OutlineNode, +} from "./outlineTree"; const NOW = "2026-07-07T00:00:00.000Z"; @@ -132,3 +138,32 @@ describe("buildForest", () => { ]); }); }); + +describe("inSubtreeOf (PROG-118 drop cycle guard)", () => { + const actions = [ + action({ id: "root", number: 1 }), + action({ id: "kid", number: 2, parentActionId: "root" }), + action({ id: "grandkid", number: 3, parentActionId: "kid" }), + action({ id: "sibling", number: 4 }), + ]; + + it("finds a node inside the subtree at any depth (and the root itself)", () => { + expect(inSubtreeOf(actions, "root", "root")).toBe(true); + expect(inSubtreeOf(actions, "root", "kid")).toBe(true); + expect(inSubtreeOf(actions, "root", "grandkid")).toBe(true); + }); + + it("rejects nodes outside the subtree", () => { + expect(inSubtreeOf(actions, "root", "sibling")).toBe(false); + expect(inSubtreeOf(actions, "kid", "root")).toBe(false); + expect(inSubtreeOf(actions, "grandkid", "kid")).toBe(false); + }); + + it("terminates on a malformed (cyclic) parent chain", () => { + const cyclic = [ + action({ id: "x", number: 1, parentActionId: "y" }), + action({ id: "y", number: 2, parentActionId: "x" }), + ]; + expect(inSubtreeOf(cyclic, "unrelated", "x")).toBe(false); + }); +}); diff --git a/src/client/outlineTree.ts b/src/client/outlineTree.ts index 22eec24..75c0846 100644 --- a/src/client/outlineTree.ts +++ b/src/client/outlineTree.ts @@ -38,6 +38,20 @@ export function siblingsOf( .sort(byRankThenNumber); } +// True when `id` sits inside `ancestorId`'s subtree (or IS it) — the client +// side of the server's cycle check (PROG-124), used to reject a drag that would +// drop an action into its own descendants (PROG-118) before the write fires. +// The hop cap mirrors the server guard against malformed parent chains. +export function inSubtreeOf(actions: WireAction[], ancestorId: string, id: string): boolean { + const parentOf = new Map(actions.map((a) => [a.id, a.parentActionId])); + let cursor: string | null | undefined = id; + for (let hops = 0; cursor != null && hops < 1000; hops++) { + if (cursor === ancestorId) return true; + cursor = parentOf.get(cursor); + } + return false; +} + // Build the rendered forest for one (focus, arc) scope; `depth` is the depth // tag for the top level (0 for loose actions, 1 inside an arc section, matching // the view's indent math). One pass groups actions by parent, then the tree is diff --git a/src/client/pages/Outline.tsx b/src/client/pages/Outline.tsx index c1a5126..facf2cd 100644 --- a/src/client/pages/Outline.tsx +++ b/src/client/pages/Outline.tsx @@ -11,21 +11,26 @@ // last sibling (→ step), Shift+Tab pops back up. Existing rows rename on // Enter/blur and reparent in place via Tab/Shift+Tab. Nothing here deletes or // archives — each row's bullet is its handle (PROG-111): tap/click opens the -// full action/arc/focus page, press-and-drag reorders. +// full action/arc/focus page, press-and-drag reorders — and, dropped outside +// its own sibling group, MOVES the action into another arc or focus (PROG-118). import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { Link, useLocation, useSearch } from "wouter"; import { closestCenter, + defaultDropAnimationSideEffects, DndContext, DragOverlay, KeyboardSensor, + MeasuringStrategy, PointerSensor, useSensor, useSensors, type DragEndEvent, + type DragOverEvent, type DragStartEvent, + type DropAnimation, } from "@dnd-kit/core"; import { SortableContext, @@ -42,15 +47,22 @@ import { createContainer, createAction, actionKeyOf, + moveAction, updateContainer, updateAction, } from "../store"; import { clearDraft, readDraft, writeDraft } from "../drafts"; -import { rankForReorder } from "../outlineReorder"; +import { rankForInsert, rankForReorder } from "../outlineReorder"; import { byRankThenName, containerReorderRanks } from "../containerReorder"; import { loadHideDone, loadScope, saveHideDone, saveScope } from "../outlinePrefs"; // Tree model + sibling rules live in outlineTree.ts (pure, unit-tested). -import { buildForest, siblingsOf, type OutlineNode as Node } from "../outlineTree"; +import { + buildForest, + byRankThenNumber, + inSubtreeOf, + siblingsOf, + type OutlineNode as Node, +} from "../outlineTree"; // ---------- level icons ---------- @@ -133,7 +145,7 @@ function Handle({ href={href} draggable={false} aria-label={label} - title="Open — drag to reorder" + title="Open — drag to move" onPointerDown={(e) => { downAt.current = { x: e.clientX, y: e.clientY }; dndPointerDown?.(e); @@ -281,6 +293,50 @@ function forestPreviewRows( return rows; } +// A held action row's overlay rows: its visible step subtree (depth-first, +// rank order), so dragging a parent reads as carrying its block — matching +// what a same-focus drop actually moves (PROG-118). +function actionSubtreeRows( + actions: WireAction[], + rootId: string, +): { key: string; depth: number; icon: ReactNode; text: string; done?: boolean }[] { + const byParent = new Map(); + for (const a of actions) { + if (a.parentActionId === null) continue; + const sibs = byParent.get(a.parentActionId); + if (sibs) sibs.push(a); + else byParent.set(a.parentActionId, [a]); + } + const rows: { key: string; depth: number; icon: ReactNode; text: string; done?: boolean }[] = []; + const walk = (id: string, depth: number) => { + for (const c of (byParent.get(id) ?? []).sort(byRankThenNumber)) { + rows.push({ + key: c.id, + depth, + icon: , + text: c.title, + done: !isOpenStatus(c.status), + }); + walk(c.id, depth + 1); + } + }; + walk(rootId, 0); + return rows; +} + +// Settle-on-drop (PROG-118 polish): the default drop tween glides the floating +// card from the release point into the row's final slot while the shadow eases +// off, and keeps the in-list source ghosted until it lands. Safe now that +// PROG-119 made optimistic writes notify synchronously — by the time the +// overlay measures its destination the row has ALREADY re-rendered at the drop +// position. (Pre-PROG-119 this measured the stale slot and the card flew back +// to it, which is why the board and the old section overlays used +// dropAnimation={null} — PROG-43.) +const DROP_ANIMATION: DropAnimation = { + duration: 180, + sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.3" } } }), +}; + // ---------- arc promotion control ---------- function ArcMenu({ action, arcs }: { action: WireAction; arcs: WireArc[] }) { @@ -444,8 +500,8 @@ function ActionRow({ // its descendants + the roving capture slot) is the sortable element, so // dragging a parent visually carries its children as a block. The activator is // the bullet handle inside ActionRow, so only it starts a drag — the title -// input keeps working normally. Reorder is constrained to siblings on drop -// (see FocusOutline.onDragEnd); reparenting stays on Tab/Shift+Tab. +// input keeps working normally. A drop within the sibling group reorders; a +// drop outside it moves the action there (see the page's onDragEnd, PROG-118). function OutlineNode({ node, ws, @@ -472,16 +528,16 @@ function OutlineNode({ transition, isDragging, } = useSortable({ id: node.action.id }); + // The grabbed subtree is carried by the page's DragOverlay (the board-card + // pattern, PROG-118 polish): the in-list source dims to a ghost but KEEPS + // its sorting translate (exactly like BoardCard), so it slides in step with + // its neighbours and marks the slot the drop would take. const style: CSSProperties = { transform: CSS.Translate.toString(transform), transition, }; return ( -
+
)} - setActiveArcId(null)} - > - {/* While an arc section is held, everything under it goes pointer-inert: - no row hover highlights, no accidental input focus — the only live - thing is the drag itself (PROG-87 polish). */} -
- {/* Focus-level (no-arc) actions + their roving capture row. */} - {renderForest(looseForest)} - {captureParent === null && captureArc === null && ( - create(t, null, null)} - onDeepen={deepen} - onShallow={shallow} - focusToken={focusToken} - /> - )} + {/* Focus-level (no-arc) actions + their roving capture row. All sortable + wiring registers with the PAGE's single DndContext (PROG-118), so rows + can be dropped across arc — and focus — section boundaries. */} + {renderForest(looseForest)} + {captureParent === null && captureArc === null && ( + create(t, null, null)} + onDeepen={deepen} + onShallow={shallow} + focusToken={focusToken} + /> + )} - {/* Arc sections — themselves drag-to-reorderable as whole blocks via the - grip in their header (PROG-87), inside the same DndContext as the - action rows (onReorder branches on what's dragged). */} - arc.id)} - strategy={verticalListSortingStrategy} + {/* Arc sections — themselves drag-to-reorderable as whole blocks via the + grip in their header (PROG-87); the page's onDragEnd branches on + what's dragged. */} + arc.id)} + strategy={verticalListSortingStrategy} + > + {arcForests.map(({ arc, forest }) => ( + - {arcForests.map(({ arc, forest }) => ( - - {(arcGrip) => ( - <> -
- {arcGrip} - - {arc.name} - -
- {renderForest(forest)} - {captureParent === null && captureArc === arc.id && ( - create(t, null, arc.id)} - onDeepen={deepen} - onShallow={shallow} - focusToken={focusToken} - /> - )} - {!(captureParent === null && captureArc === arc.id) && ( - - )} - + {(arcGrip) => ( + <> +
+ {arcGrip} + + {arc.name} + +
+ {renderForest(forest)} + {captureParent === null && captureArc === arc.id && ( + create(t, null, arc.id)} + onDeepen={deepen} + onShallow={shallow} + focusToken={focusToken} + /> )} -
- ))} -
- - {/* When capture has roved off the top level, offer a way back. */} - {!captureAtTopLevel && ( - - )} -
+ {!(captureParent === null && captureArc === arc.id) && ( + + )} + + )} + + ))} + - {/* The floating copy of the held arc section: follows the pointer from - the first pixel, lifted above the page (shadow), capped to a few rows. - dropAnimation={null} for the same reason as the board (PROG-43): the - reorder is committed on drop, so the default tween would fly the card - back to its OLD slot before snapping. */} - - {(() => { - const held = activeArcId - ? arcForests.find(({ arc }) => arc.id === activeArcId) - : undefined; - if (!held) return null; - const rows = forestPreviewRows(held.forest); - return ( - - - {held.arc.name} - - } - rows={rows} - more={rows.length - PREVIEW_ROWS} - /> - ); - })()} - -
+ {/* When capture has roved off the top level, offer a way back. */} + {!captureAtTopLevel && ( + + )} ); } @@ -1135,28 +1081,288 @@ export default function Outline({ snapshot }: { snapshot: SnapshotPayload }) { ? focuses.filter((p) => p.workspaceId === root.id) : []; - // At workspace scope the focus sections are drag-to-reorderable (PROG-87). - // This outer DndContext nests around each FocusOutline's own context; the - // focus grip is the only activator registered here, so arc/action drags - // inside a section never reach this handler. - const focusSensors = useSensors( + // ---------- the page-wide drag controller (PROG-86/87/118) ---------- + // + // ONE DndContext for the whole page: focus sections, arc sections, and action + // rows all register here (they used to be split across nested per-focus + // contexts, which made a drag across section boundaries impossible). The + // handlers branch on what was picked up: sections reorder among their + // siblings as before, while an action row dropped outside its own sibling + // group now MOVES there — into another arc, back to the loose level, or into + // a whole different focus (PROG-118). + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - const [activeFocusId, setActiveFocusId] = useState(null); - const onFocusReorder = (e: DragEndEvent) => { - setActiveFocusId(null); + + // What the forests render (PROG-77): with "hide done" on, completed actions + // and their subtrees drop out. Drop targets resolve against this same list so + // a drop slots among the rows the user actually sees. + const visibleActions = useMemo( + () => (hideDone ? snapshot.actions.filter((i) => isOpenStatus(i.status)) : snapshot.actions), + [snapshot.actions, hideDone], + ); + const actionById = useMemo( + () => new Map(snapshot.actions.map((i) => [i.id, i])), + [snapshot.actions], + ); + const arcById = useMemo(() => new Map(snapshot.arcs.map((a) => [a.id, a])), [snapshot.arcs]); + const focusById = useMemo(() => new Map(focuses.map((p) => [p.id, p])), [focuses]); + const rankOf = (id: string) => actionById.get(id)!.rank; + + // Whatever the drag is holding. While set, a DragOverlay carries a floating + // preview of it (board-card pattern: instant pickup feedback that tracks the + // pointer) and the page goes pointer-inert, so nothing hover-highlights + // under the drag (PROG-87 polish). + const [activeDrag, setActiveDrag] = useState<{ + kind: "focus" | "arc" | "action"; + id: string; + } | null>(null); + // A held action row's LIVE landing spot (PROG-118 polish). While the drag + // hovers a different sibling group, the row is rendered *in that group* at + // this position (see previewedActions), so the underlying rows slide apart to + // show where it would land — across arcs and focuses, the board's + // onDragOver-preview pattern (PROG-59). Null while the row is over its home + // group, where dnd-kit's same-context sorting transforms show the gap. + const [preview, setPreview] = useState<{ + focusId: string; + arcId: string | null; + parentActionId: string | null; + rank: string; + } | null>(null); + const clearDrag = () => { + setActiveDrag(null); + setPreview(null); + }; + const onDragStart = (e: DragStartEvent) => { + const id = String(e.active.id); + if (focusById.has(id)) setActiveDrag({ kind: "focus", id }); + else if (arcById.has(id)) setActiveDrag({ kind: "arc", id }); + else if (actionById.has(id)) setActiveDrag({ kind: "action", id }); + }; + + // What the forests actually render: while an action drag previews into + // another group, the row is patched to that spot so the whole page reflects + // the pending drop. + const previewedActions = useMemo(() => { + if (!preview || activeDrag?.kind !== "action") return visibleActions; + return visibleActions.map((a) => (a.id === activeDrag.id ? { ...a, ...preview } : a)); + }, [visibleActions, activeDrag, preview]); + + // A cross-focus move always lands top-level (the server detaches steps, + // PROG-124) — so a drop over a step slots relative to its top-level root. + const rootAncestorOf = (a: WireAction): WireAction => { + let cursor = a; + for (let hops = 0; cursor.parentActionId !== null && hops < 1000; hops++) { + const parent = actionById.get(cursor.parentActionId); + if (!parent) break; + cursor = parent; + } + return cursor; + }; + + // One sibling group per key: steps group under their parent, top-level rows + // under their (focus, arc) — mirrors siblingsOf's scoping rule. + const groupKeyOf = (g: { + focusId: string; + arcId: string | null; + parentActionId: string | null; + }) => + g.parentActionId !== null + ? `${g.focusId}/p:${g.parentActionId}` + : `${g.focusId}/a:${g.arcId ?? "-"}`; + + // Where a dragged action would land if released over `overId`: the target + // sibling group and a rank inside it. Rows resolve to their own group (their + // top-level root's group when the row is in another focus — a move lands + // top-level); arc/focus sections resolve to their top level, appended. + // Returns null for an unresolvable or forbidden target (own subtree). + const resolveActionDrop = ( + active: WireAction, + overId: string, + below: boolean, + ): { + focusId: string; + arcId: string | null; + parentActionId: string | null; + rank: string; + } | null => { + const target = (() => { + const overAction = actionById.get(overId); + if (overAction) { + // Never into the action's own subtree — the reparent would cycle (the + // server rejects it too; this guard skips the doomed write). + if (inSubtreeOf(snapshot.actions, active.id, overId)) return null; + const anchor = + overAction.focusId === active.focusId ? overAction : rootAncestorOf(overAction); + return { + focusId: anchor.focusId, + arcId: anchor.arcId, + parentActionId: overAction.focusId === active.focusId ? anchor.parentActionId : null, + anchorId: anchor.id, + }; + } + const overArc = arcById.get(overId); + if (overArc) + return { + focusId: overArc.focusId, + arcId: overArc.id, + parentActionId: null, + anchorId: overId, + }; + const overFocus = focusById.get(overId); + if (overFocus) + return { focusId: overFocus.id, arcId: null, parentActionId: null, anchorId: overId }; + return null; + })(); + if (!target) return null; + // The group as rendered, without the active row; anchorId not in it (a + // section id) means "append to the end" — rankForInsert's fallback. + const group = siblingsOf( + visibleActions, + target.focusId, + target.parentActionId, + target.arcId, + ).filter((i) => i.id !== active.id); + const { anchorId, ...fields } = target; + return { + ...fields, + rank: rankForInsert( + group.map((i) => i.id), + rankOf, + anchorId, + below, + ), + }; + }; + + // Pointer past the hovered target's vertical middle → land below it (the + // board's cross-column rule); within one sibling group the index math + // decides the side instead. + const belowOf = (e: DragOverEvent | DragEndEvent) => { + const translated = e.active.rect.current.translated; + return translated && e.over ? translated.top > e.over.rect.top + e.over.rect.height / 2 : false; + }; + + // Live preview while an action row is held (the board's PROG-59 pattern): + // when the hovered target resolves to a DIFFERENT sibling group, re-home the + // row there so that group opens a slot. Inside one group (home or previewed) + // this stays out of the way — dnd-kit's sorting transforms already animate + // the gap, and re-rendering against them would fight. + const onDragOver = (e: DragOverEvent) => { + if (activeDrag?.kind !== "action") return; + const overId = e.over ? String(e.over.id) : null; + if (!overId || overId === activeDrag.id) return; + const active = actionById.get(activeDrag.id); + if (!active) return; + const resolved = resolveActionDrop(active, overId, belowOf(e)); + if (!resolved) return; + const homeKey = groupKeyOf(active); + const currentKey = preview ? groupKeyOf(preview) : homeKey; + const targetKey = groupKeyOf(resolved); + if (targetKey === currentKey) return; + setPreview(targetKey === homeKey ? null : resolved); + }; + + const onDragEnd = (e: DragEndEvent) => { + const dropPreview = activeDrag?.kind === "action" ? preview : null; + clearDrag(); const activeId = String(e.active.id); const overId = e.over ? String(e.over.id) : null; - if (!overId || overId === activeId) return; - const updates = containerReorderRanks(scopedFocuses, activeId, overId); - for (const u of updates ?? []) void updateContainer("focus", u.id, { rank: u.rank }); + if (!overId) return; + + // -- A focus section (workspace scope): reorder among the visible focuses. + // With closestCenter the `over` is often a row/arc inside a neighbouring + // section rather than the section itself — resolve it to its focus. + if (focusById.has(activeId)) { + const overFocusId = focusById.has(overId) + ? overId + : (arcById.get(overId)?.focusId ?? actionById.get(overId)?.focusId ?? null); + if (!overFocusId || overFocusId === activeId) return; + // One write once ranks are distinct; the first drag in a still-tied + // (alphabetical) group renumbers the whole group — see containerReorder. + const updates = containerReorderRanks(scopedFocuses, activeId, overFocusId); + for (const u of updates ?? []) void updateContainer("focus", u.id, { rank: u.rank }); + return; + } + + // -- An arc section: reorder among its own focus's arcs. Only actions move + // between containers by drag (PROG-118); an arc dropped outside its + // focus stays put. + const activeArc = arcById.get(activeId); + if (activeArc) { + const overArc = arcById.get( + arcById.has(overId) ? overId : (actionById.get(overId)?.arcId ?? ""), + ); + if (!overArc || overArc.focusId !== activeArc.focusId || overArc.id === activeId) return; + const focusArcs = snapshot.arcs + .filter((a) => a.focusId === activeArc.focusId && !a.archivedAt) + .sort(byRankThenName); + const updates = containerReorderRanks(focusArcs, activeId, overArc.id); + for (const u of updates ?? []) void updateContainer("arc", u.id, { rank: u.rank }); + return; + } + + // -- An action row (PROG-86/PROG-118). The row is committed where the + // preview left it: onDragOver has already resolved every cross-group + // hop, so by release the pending group is `dropPreview` (or home), and + // `over` only fine-tunes the position within it. Resolving `over` from + // scratch here would break exactly the way the board's PROG-59 fix + // describes — after a preview, `over` is usually the active row itself. + const active = actionById.get(activeId); + if (!active) return; + const target = dropPreview ?? { + focusId: active.focusId, + arcId: active.arcId, + parentActionId: active.parentActionId, + rank: active.rank, + }; + // The landing group as rendered at release (active row at its previewed + // spot), so the within-group reorder math sees what the user saw. + const listAtDrop = dropPreview + ? visibleActions.map((a) => (a.id === activeId ? { ...a, ...dropPreview } : a)) + : visibleActions; + const group = siblingsOf(listAtDrop, target.focusId, target.parentActionId, target.arcId); + let reordered: string | null = null; + if (overId !== activeId && group.some((i) => i.id === overId)) { + // Released over a sibling: mint a rank between its new neighbours — the + // same shared `rank` the board writes, so this drag also moves the card + // there and vice-versa (PROG-86). + reordered = rankForReorder( + group.map((i) => i.id), + (id) => group.find((i) => i.id === id)!.rank, + activeId, + overId, + ); + } + if (!dropPreview) { + // Never left home: a plain same-group reorder, or a no-op click. + if (reordered) void updateAction(activeId, { rank: reordered }); + return; + } + const rank = reordered ?? dropPreview.rank; + if (dropPreview.focusId === active.focusId) { + // Same focus: join the previewed group right where shown — one + // optimistic PATCH covers arc → arc, arc ↔ loose, and step groups. + void updateAction(activeId, { + arcId: target.arcId, + parentActionId: target.parentActionId, + rank, + }); + } else { + // Another focus: a real move (re-key + alias, steps detach — + // PROG-102/PROG-124), landing top-level at the previewed spot. + moveAction(activeId, { focusId: target.focusId, arcId: target.arcId, rank }); + } }; - // Overlay preview for a held focus section: its arcs as rows (the level a - // focus grouping is made of), matching the arc previews' capped card. - const heldFocus = activeFocusId ? scopedFocuses.find((p) => p.id === activeFocusId) : undefined; - const heldFocusRows = heldFocus + // What the DragOverlay carries: a held focus shows its arcs as rows, a held + // arc its action forest, a held action row its step subtree — capped preview + // cards all three ways. + const heldFocus = activeDrag?.kind === "focus" ? focusById.get(activeDrag.id) : undefined; + const heldArc = activeDrag?.kind === "arc" ? arcById.get(activeDrag.id) : undefined; + const heldAction = activeDrag?.kind === "action" ? actionById.get(activeDrag.id) : undefined; + const heldRows = heldFocus ? [...snapshot.arcs] .filter((a) => a.focusId === heldFocus.id && !a.archivedAt) .sort(byRankThenName) @@ -1166,7 +1372,11 @@ export default function Outline({ snapshot }: { snapshot: SnapshotPayload }) { icon: , text: a.name, })) - : []; + : heldArc + ? forestPreviewRows(buildForest(visibleActions, heldArc.focusId, heldArc.id, 1)) + : heldAction + ? actionSubtreeRows(visibleActions, heldAction.id) + : []; return (
@@ -1176,7 +1386,7 @@ export default function Outline({ snapshot }: { snapshot: SnapshotPayload }) {

Fast capture — type to add actions, Enter for the next, Tab/ Shift+Tab to nest. Each row's bullet is its handle — tap it to open, - drag it to reorder. + drag it to reorder or drop it into another arc or focus.

@@ -1234,23 +1444,27 @@ export default function Outline({ snapshot }: { snapshot: SnapshotPayload }) {
{!root &&

No focuses or workspaces yet.

} - {root?.kind === "workspace" ? ( - setActiveFocusId(String(e.active.id))} - onDragEnd={onFocusReorder} - onDragCancel={() => setActiveFocusId(null)} - > + + {root?.kind === "workspace" ? ( p.id)} strategy={verticalListSortingStrategy} > - {/* Pointer-inert while a focus section is held — see the arc - drag's identical wrapper (PROG-87 polish). */} -
+ {/* Pointer-inert while anything is held: no row hover + highlights, no accidental input focus — the only live thing + is the drag itself (PROG-87 polish). */} +
{scopedFocuses.map((p) => ( )} @@ -1272,35 +1486,77 @@ export default function Outline({ snapshot }: { snapshot: SnapshotPayload }) { ))}
- - {heldFocus && ( + ) : ( +
+ {scopedFocuses.map((p) => ( + + ))} +
+ )} + + {/* The floating copy of whatever is held — section or action row: + follows the pointer from the first pixel, lifted above the page + (shadow), capped to a few rows. On release DROP_ANIMATION glides + it into the committed slot (see its comment for why that no + longer bounces back). */} + + {heldFocus ? ( + + + {heldFocus.name} + + {heldFocus.keyPrefix} + + + } + rows={heldRows} + more={heldRows.length - PREVIEW_ROWS} + /> + ) : heldArc ? ( + + + {heldArc.name} + + } + rows={heldRows} + more={heldRows.length - PREVIEW_ROWS} + /> + ) : heldAction ? ( + // The board card's held look (rotate + lift) on the row's own + // anatomy, so what you grabbed is unmistakably in hand. Width + // capped: the sortable node is a full-width row, but the thing + // in hand should read as a card, not a page-wide slab. +
- - {heldFocus.name} - - {heldFocus.keyPrefix} + + + {heldAction.title} } - rows={heldFocusRows} - more={heldFocusRows.length - PREVIEW_ROWS} + rows={heldRows} + more={heldRows.length - PREVIEW_ROWS} /> - )} - - - ) : ( - scopedFocuses.map((p) => ( - - )) - )} +
+ ) : null} +
+ {/* At workspace scope, focuses are the top ceiling — so offer inline focus capture (and seed the empty state). Focus scope has no diff --git a/src/client/store.test.ts b/src/client/store.test.ts index 1e791ca..78d7143 100644 --- a/src/client/store.test.ts +++ b/src/client/store.test.ts @@ -217,6 +217,20 @@ describe("moveAction rollback", () => { expect(current().actionKeyAliases).toEqual([]); }); + it("lands in the named target arc at the named rank (PROG-118 drop), and rolls back", async () => { + const ws = seed(); + globalThis.fetch = (() => Promise.reject(new Error("network down"))) as typeof fetch; + + moveAction("act_parent", { focusId: "foc_b", arcId: "arc_target", rank: "Q7" }); + + expect(byId("act_parent").focusId).toBe("foc_b"); + expect(byId("act_parent").arcId).toBe("arc_target"); + expect(byId("act_parent").rank).toBe("Q7"); + + await settle(); + expect(byId("act_parent")).toEqual(ws.actions[0]!); + }); + it("is a no-op when the target focus is the action's current focus", async () => { seed(); let fetched = false; diff --git a/src/client/store.ts b/src/client/store.ts index c29b232..5cec7e1 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -438,13 +438,17 @@ export function createAction(input: ActionCreateInput): string | undefined { // ---------- action movement ---------- -export type MoveTarget = { focusId: string }; +// The optional landing spot (PROG-118): the Outline's cross-focus drag names +// the arc (of the TARGET focus) and rank where the action was released; +// palette moves omit both and land loose at the action's current rank. +export type MoveTarget = { focusId: string; arcId?: string | null; rank?: string }; // Optimistic move (SPEC §3, PROG-102): a move changes the focus (the sole // container). The action is re-keyed from the target's sequence, its arc is -// cleared, and the old key is appended to the alias list — all locally first, -// so the board and any open action page (which redirects via the alias) update -// instantly. Rollback restores exactly what this move touched. +// cleared (or set to the named landing arc, PROG-118), and the old key is +// appended to the alias list — all locally first, so the board and any open +// action page (which redirects via the alias) update instantly. Rollback +// restores exactly what this move touched. export function moveAction(id: string, target: MoveTarget) { const ws = queryClient.getQueryData(WS_KEY); const before = ws?.actions.find((i) => i.id === id); @@ -470,11 +474,12 @@ export function moveAction(id: string, target: MoveTarget) { ? { ...i, focusId: target.focusId, - arcId: null, + arcId: target.arcId ?? null, // Cross-focus move drops the parent and detaches children // (PROG-124) — mirrors the server's move handler. parentActionId: null, number: targetFocus.nextActionNumber, + ...(target.rank !== undefined ? { rank: target.rank } : {}), updatedAt: now, } : i.parentActionId === id diff --git a/src/worker/index.ts b/src/worker/index.ts index 7bd2e98..9b186a3 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1027,15 +1027,20 @@ app.post("/api/actions", async (c) => { return c.json({ action }, 201); }); -type ActionMoveBody = { focusId?: unknown }; +type ActionMoveBody = { focusId?: unknown; arcId?: unknown; rank?: unknown }; // Action movement (SPEC §3, PROG-102): a move now only changes the focus (the // sole container). It re-keys from the target's sequence, clears the arc, and // retires the old key into action_key_aliases as a permanent redirect (D18). +// The Outline's cross-focus drag (PROG-118) may name a landing spot: an arc of +// the TARGET focus and/or a rank, so the action drops exactly where released +// instead of always landing loose at the default position. app.post("/api/actions/:id/move", async (c) => { const id = c.req.param("id"); const body = (await c.req.json()) as ActionMoveBody; if (typeof body.focusId !== "string") return c.json({ error: "focusId is required" }, 400); + if (body.rank !== undefined && !isValidRank(body.rank)) + return c.json({ error: `invalid rank: ${String(body.rank)}` }, 400); const db = drizzle(c.env.DB); const [existing] = await db.select().from(actions).where(eq(actions.id, id)).limit(1); @@ -1045,6 +1050,15 @@ app.post("/api/actions/:id/move", async (c) => { if (existing.focusId === target.id) return c.json({ error: "action is already in that focus" }, 400); + let landingArcId: string | null = null; + if (body.arcId !== undefined && body.arcId !== null) { + if (typeof body.arcId !== "string") return c.json({ error: "arcId must be a string" }, 400); + const [arc] = await db.select().from(arcs).where(eq(arcs.id, body.arcId)).limit(1); + if (!arc || arc.focusId !== target.id) + return c.json({ error: "arc not found in the target focus" }, 400); + landingArcId = arc.id; + } + const now = new Date(); const moveData = { fromFocusId: existing.focusId, @@ -1067,13 +1081,15 @@ app.post("/api/actions/:id/move", async (c) => { db .update(actions) // arcId and parentActionId reference the old focus, so a cross-focus - // move clears both — the action lands at the top level of the target - // (PROG-124). Any children keep pointing here and are detached below. + // move clears both — unless the caller named a landing arc IN the target + // (PROG-118); either way the action lands at the top level (PROG-124). + // Any children keep pointing here and are detached below. .set({ focusId: target.id, - arcId: null, + arcId: landingArcId, parentActionId: null, number, + ...(body.rank !== undefined ? { rank: body.rank as string } : {}), updatedAt: now, }) .where(eq(actions.id, id))