Skip to content
34 changes: 27 additions & 7 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`. |
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions docs/decisions/PROG-118.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 12 additions & 8 deletions e2e/outline-container-reorder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ async function yOrder(page: Page, names: string[]): Promise<string[]> {
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 <name> — 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);
Expand Down Expand Up @@ -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"]))
Expand Down Expand Up @@ -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"]))
Expand Down
Loading