Public: anyone can read this; the crew can change it.

On /next the owner moves a row by keyboard: select, up or down, t to top, l to Later #12305

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11755-on-next-the-owner-moves-a-row-by-keyboar → main 8 commits fork 5 threads · 5 unresolved

  • Merged. scout/11755-on-next-the-owner-moves-a-row-by-keyboar was merged into main as 533db21.

Review

Sign in to review this change.
7 files changed +136 −13
docs/features/next-up.md +3 −3
@@ -3,7 +3,7 @@
33 `/:owner/:name/next` is the one ranked list the crew builds from (#8694). It reads as one numbered queue in the dispatcher's order (#11619): each row has its position, title, who is on it (or "next"), and one word of state: building, checking, in review, landing, waiting on #N, held or ready. A pull that is open on the issue sets building, checking or in review. Three native folds hold the rows, each with a count: "Up next" (the first ten, open), "After" (the rest) and "Later" (behind the Later gate). There is no script for the folds and no `?all=1`; the folds are keyboard and screen-reader reachable at any width. The terminal's Next up screen shows the same numbers and state words.
44
55 - **Role:** each row is tagged *Blocker* (it holds other open work up: "finishing it frees N") or *Enabler* (it holds nothing up; its line says what it serves, else that the owner ranked it). The tag is a label only: it never changes the order the picker takes work in.
6 - **Move:** `Top` (`t`), `Up` (`u`), `Down` (`d`) and `Not now` (`n`) are forms on each row, with the key shown beside each; `j`/`k` choose the row, and a row can be dragged. Each is one `POST /:owner/:name/next` (`number`, `to` = `top`, `not-now` or `after:<number>`) and saves at once. Writers only; the same-origin check applies.
6 - **Move:** `Top` (`t`), `Up` (`u`), `Down` (`d`) and `Not now` (`l`) are forms on each row, with the key shown beside each (Shift+J/K or Alt+↓/↑ also move down and up; Alt+Shift combos do nothing); `j`/`k` choose the row, and a row can be dragged. Each is one `POST /:owner/:name/next` (`number`, `to` = `top`, `not-now` or `after:<number>`) and saves at once. Writers only; the same-origin check applies.
77 - **Top lifts the gate:** `Top` on a deferred issue removes its `wait_for` edge to the Later gate (and the Later umbrella's edge to it) in the same action. Work that only deferred work needs stays deferred, and the refusal says so.
88 - **Owner's pick:** a row a person placed (page, terminal or MCP) is marked "Owner's pick" on the page and in the terminal. Automatic ranking leaves it at its place and orders only the other rows around it; only a person's move changes it, and `Not now` clears it along with the rank. A move of another row does not mark the rows the planner placed. Nothing calls the automatic mode yet (`writeOrder` with `owner: false`): the guarantee is in place and tested, ahead of its first caller.
99 - **Not now** links the issue to wait for the open Later gate and drops its rank.
@@ -11,9 +11,9 @@
1111 - **MCP:** `next_up { app, issue?, to?, all? }` reads the list, or moves one issue with the same words.
1212 - A rank is cleared when the issue closes. Dependencies order within the list and are shown, never edited here.
1313
14 **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `n` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so.
14 **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `l` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so.
1515
16 **Filter (#11867):** a plain GET form above the list narrows `/next` to one pillar (`?pillar=P7` or `Later`, typed in a box) and/or a word (`?q=cache`, matched without regard to case, shown as typed, at most 80 characters). A filter only hides rows: each keeps its real place in the whole ranking, the cut line stays where the whole list puts it (drawn only between shown rows), and a move made under a filter moves in the whole order and returns to the same filter. A line says "N of M shown", and that the week's capacity bar is left out while filtered. An unknown or malformed pillar shows an empty list that says so. **Terminal:** `/` asks for a pillar and/or a word (`P7 cache`); an empty answer clears it; ranks, `t` `u` `d` `n` and the header count behave as on the page.
16 **Filter (#11867):** a plain GET form above the list narrows `/next` to one pillar (`?pillar=P7` or `Later`, typed in a box) and/or a word (`?q=cache`, matched without regard to case, shown as typed, at most 80 characters). A filter only hides rows: each keeps its real place in the whole ranking, the cut line stays where the whole list puts it (drawn only between shown rows), and a move made under a filter moves in the whole order and returns to the same filter. A line says "N of M shown", and that the week's capacity bar is left out while filtered. An unknown or malformed pillar shows an empty list that says so. **Terminal:** `/` asks for a pillar and/or a word (`P7 cache`); an empty answer clears it; ranks, `t` `u` `d` `l` and the header count behave as on the page.
1717
1818 ## The week is the sprint (#8704)
1919
src/tui/screens/next.ts +2 −2
@@ -158,7 +158,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
158158 binding(["t"], "top", { disabled: !on || rows.indexOf(current()!) === 0 }),
159159 binding(["u"], "up", { disabled: !on || rows.indexOf(current()!) === 0 }),
160160 binding(["d"], "down", { disabled: !on || rows.indexOf(current()!) >= rows.length - 1 }),
161 binding(["n"], "not now", { disabled: !on }),
161 binding(["l"], "not now", { disabled: !on }),
162162 binding(["/"], "filter"),
163163 ];
164164 },
@@ -187,7 +187,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
187187 else if (e.key === "u") step(-1);
188188 else if (e.key === "d") step(1);
189189 else if (e.key === "/") ask();
190 else if (e.key === "n") rank({ to: "not_now" });
190 else if (e.key === "l") rank({ to: "not_now" });
191191 else return false;
192192 return true;
193193 },
src/web/pages/next.ts +8 −4
@@ -6,6 +6,7 @@
66 * phone need no script; a row can also be dragged, or moved by the key shown
77 * beside each action (`j`/`k` choose the row), which post the same form.
88 */
9 import { keyStep } from "./next_keys.ts";
910 import { placeOf, rankIssue, rankedWords, stepTo, type Place } from "../../actions/next.ts";
1011 import { currentByNumber } from "../../core/estimates.ts";
1112 import { estimateLine } from "../../flow/estimate_words.ts";
@@ -48,6 +49,7 @@ export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; }
4849
4950 export const KEYS_JS = `
5051 (() => {
52 ${keyStep.toString()}
5153 const list = document.getElementById("next-list");
5254 const remembered = sessionStorage.getItem("next-focus");
5355 if (remembered) {
@@ -71,9 +73,11 @@ export const KEYS_JS = `
7173 };
7274 // One key, one action, on the row that has focus: the key is shown on the button.
7375 document.addEventListener("keydown", (e) => {
74 if (e.metaKey || e.ctrlKey || e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
76 if (e.metaKey || e.ctrlKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
outdated

Nested ternary is unreadable and untested. Use a lookup keyed by modifier state, and test it. Note Alt+Shift combos silently do nothing; fine, but say so in the help.

77 const step = keyStep(e);
78 if (!step) return;
7579 const row = document.activeElement && document.activeElement.closest("li[data-number]");
76 const form = row && row.querySelector('form[data-key="' + e.key + '"]');
80 const form = row && row.querySelector('form[data-key="' + step + '"]');
7781 if (!form) return;
7882 e.preventDefault();
7983 send(new FormData(form), row.dataset.number);
@@ -171,7 +175,7 @@ function line(
171175 ? null
172176 : html`${up === undefined ? null : move(base, n, up, "Up", "u", back)}${down === undefined
173177 ? null
outdated

Key becomes l here but the TUI (src/tui/screens/next.ts:161,190) and docs/features/next-up.md still use n. Principle 8 / both doors: align them.

174 : move(base, n, down, "Down", "d", back)}${move(base, n, "not-now", "Not now", "n", back)}`}`
178 : move(base, n, down, "Down", "d", back)}${move(base, n, "not-now", "Not now", "l", back)}`}`
175179 : null;
176180 return row({
177181 href: `${base}/issues/${n}`,
@@ -253,7 +257,7 @@ export function nextPage(ctx: Ctx): Response {
253257 : html`${bar}${isFiltered ? html`<p class="muted" id="next-count">${countWords(ats.length, rows.length)} shown${ats.length + parked.length === 0 ? html`. ${nothingWords}` : null}. Numbers and the cut line are the whole list's; the week's capacity bar is left out while filtered.</p>` : null}
254258 ${first.length > 0 ? section(isFiltered ? "Matching" : "Up next", first, true, "next-list") : null}${rest.length > 0 ? section("After", rest, false) : null}`}
255259 <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer
outdated

Principle 1: the help line is now one long parenthetical sentence that restates what the button labels already show. Shorten it to the keys only, e.g. Keys: t top · l Later · Shift+J/K or Alt+↑/↓ down/up · j/k choose a row.

256 ? " · Drag a row, or press the key on its button; j and k move between rows."
260 ? " · Drag a row. Keys: t top · l Later · Shift+J/K or Alt+↓/↑ down/up · j/k choose a row."
257261 : null}</p>
258262 ${parked.length > 0
259263 ? html`<details class="mt"><summary><h2>Later · ${String(isFiltered ? parked.length : next.notNowTotal)}</h2></summary><ul class="list setting-list">
src/web/pages/next_keys.ts added +12
@@ -0,0 +1,12 @@
1 /** The /next key mapping as one pure function: a key event in, the form key (data-key) it
2 * presses out. */
3
4 /** Plain keys press their own form; Shift+J/K and Alt+arrows are Down and Up; Alt+Shift combos do
5 * nothing. Self-contained and untyped: it is shipped to the browser as its own source. */
6 export function keyStep(e: { key: string; shiftKey: boolean; altKey: boolean }): string | undefined {
7 const table: Record<string, Record<string, string>> = { shift: { J: "d", K: "u" }, alt: { ArrowUp: "u", ArrowDown: "d" } };
8 if (e.altKey && e.shiftKey) return undefined;
9 if (e.altKey) return table.alt![e.key];

Plain-object lookup returns inherited properties for keys like "constructor"/"toString"; harmless here (no form matches) but use Object.hasOwn or a Map so the function only ever returns u, d or undefined.

10 if (e.shiftKey) return table.shift![e.key];
11 return e.key;
12 }
test/next-keys.test.ts added +107
@@ -0,0 +1,107 @@
1 /** /next keys (#11755): each key is a `data-key` form on the row; the key posts that same form. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { nextUp } from "../src/core/next_up.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { keyStep } from "../src/web/pages/next_keys.ts";
15 import { KEYS_JS } from "../src/web/pages/next.ts";
16
17 let dir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie: string;
21 let repo: Repo;
22 let numbers: number[];
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-next-keys-"));
26 app = createApp({ dataDir: dir, port: 0 });
27 servers = await serve(app);
28 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
29 if (!made.ok) throw new Error(made.error);
30 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
31 const r = await createRepo(app, made.value, { name: "keys", visibility: "public" });
32 if (!r.ok) throw new Error(JSON.stringify(r.error));
33 repo = r.value;
34 const gate = openIssue(app, made.value, repo, { title: "Later gate: not now" });
35 if (!gate.ok) throw new Error(String(gate.error));
36 for (const t of ["A", "B", "C", "D"]) {
37 const o = openIssue(app, made.value, repo, { title: `Row ${t}` });
38 if (!o.ok) throw new Error(String(o.error));
39 }
40 numbers = order();
41 });
42
43 afterAll(async () => {
44 await servers.stop();
45 app.close();
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 const order = () => nextUp(app.db, repo).rows.map((r) => r.issue.number);
50 const page = async () => (await fetch(`${servers.url}/alice/keys/next`, { headers: { cookie } })).text();
51
52 /** The fields of the row's form for a key, as the browser would submit them. */
53 function formFor(html: string, number: number, key: string): URLSearchParams {
54 const li = new RegExp(`<li[^>]*data-number="${number}"[\\s\\S]*?</li>`).exec(html)![0];
55 const form = new RegExp(`<form[^>]*data-key="${key}"[\\s\\S]*?</form>`).exec(li)?.[0];
56 expect(form).toBeDefined();
57 const body = new URLSearchParams();
58 for (const m of form!.matchAll(/<input type="hidden" name="(\w+)" value="([^"]*)"/g)) body.set(m[1]!, m[2]!.replaceAll("&amp;", "&"));
59 return body;
60 }
61
62 const press = async (number: number, key: string) => {
63 const body = formFor(await page(), number, key);
64 const res = await fetch(`${servers.url}/alice/keys/next`, { method: "POST", headers: { cookie, origin: servers.url, accept: "application/json" }, body });
65 expect(res.ok).toBe(true);
66 };
67
68 test("the page carries the key hooks and lists them in its help", async () => {
69 const html = await page();
70 const row = new RegExp(`<li[^>]*data-number="${numbers[1]}"[\\s\\S]*?</li>`).exec(html)![0];
71 for (const k of ["t", "u", "d", "l"]) expect(row).toContain(`data-key="${k}"`);
72 expect(html).toContain("t top · l Later");
73 expect(html).toContain("Shift+J");
74 expect(html).toContain("Alt+");
75 expect(KEYS_JS).toContain("next-focus");
76 });
77
outdated

These assertions check source substrings, not behaviour; they would pass with a broken mapping. Test the mapping function directly.

78 test("each key submits the existing form and the order follows", async () => {
79 const [a, b, c, d] = numbers as [number, number, number, number];
80 await press(c, "t"); // t: to the top
81 expect(order()).toEqual([c, a, b, d]);
82 await press(c, "d"); // Shift+J / Alt+Down: one place down
83 expect(order()).toEqual([a, c, b, d]);
84 await press(d, "u"); // Shift+K / Alt+Up: one place up
85 expect(order()).toEqual([a, c, d, b]);
86 await press(a, "l"); // l: behind the Later gate
87 expect(order()).toEqual([c, d, b]);
88 });
89
90 const ev = (key: string, mods: { shiftKey?: boolean; altKey?: boolean } = {}) => ({ key, shiftKey: false, altKey: false, ...mods });
91
92 test("keyStep maps modifiers to the form keys", () => {
93 expect(keyStep(ev("t"))).toBe("t");
94 expect(keyStep(ev("l"))).toBe("l");
95 expect(keyStep(ev("J", { shiftKey: true }))).toBe("d");
96 expect(keyStep(ev("K", { shiftKey: true }))).toBe("u");
97 expect(keyStep(ev("ArrowDown", { altKey: true }))).toBe("d");
98 expect(keyStep(ev("ArrowUp", { altKey: true }))).toBe("u");
99 expect(keyStep(ev("ArrowUp"))).toBe("ArrowUp"); // plain arrows have no form: no row lookup matches
100 expect(keyStep(ev("J", { altKey: true }))).toBeUndefined();
101 expect(keyStep(ev("T", { shiftKey: true }))).toBeUndefined();
102 expect(keyStep(ev("ArrowDown", { altKey: true, shiftKey: true }))).toBeUndefined();
103 });
104
105 test("the shipped script carries the same function", () => {
106 expect(KEYS_JS).toContain(keyStep.toString());
107 });
test/next-up.test.ts +2 −2
@@ -180,7 +180,7 @@ describe("Next up", () => {
180180 expect(writer).toContain(`value="${n.b}"`);
181181 expect(writer).toContain("Not now");
182182 // The key is shown beside each action, and the rows take j/k.
183 for (const key of ["t", "u", "d", "n"]) expect(writer).toContain(`data-key="${key}"`);
183 for (const key of ["t", "u", "d", "l"]) expect(writer).toContain(`data-key="${key}"`);
184184 expect(writer).toContain("<kbd>t</kbd>");
185185 expect(writer).toContain("data-nav");
186186 expect(writer).toContain("Blocker");
@@ -349,7 +349,7 @@ describe("Next up page shape (#9764)", () => {
349349 expect(main).not.toMatch(/class="card\b/);
350350 // One sentence in the head, from pageHead; the key hint sits after the list, not in the lead.
351351 expect(main).toMatch(/<div class="page-head"> ?<h1>Next up<\/h1> ?<span class="page-head-sub muted small">The crew builds strictly from the top\.<\/span>/);
352 expect(main.indexOf("j and k move between rows")).toBeGreaterThan(main.indexOf("</ul>"));
352 expect(main.indexOf("j/k choose a row")).toBeGreaterThan(main.indexOf("</ul>"));
353353 expect(body).not.toMatch(/class="[^"]*\bbadge\b/);
354354 expect(body).not.toMatch(/<(?:span|em|strong)[^>]*badge/);
355355
test/tui-next-up.test.ts +2 −2
@@ -117,7 +117,7 @@ describe("next up in the terminal", () => {
117117
118118 // Not now puts the row behind the Later gate.
119119 const first = order()[0]!;
120 await h.press("n");
120 await h.press("l");
121121 expect(order()).not.toContain(first);
122122 expect(nextUp(forge.db, repo).notNow.map((r) => r.issue.number)).toContain(first);
123123
@@ -179,7 +179,7 @@ describe("next up in the terminal", () => {
179179 await h.toNext("alice/plan");
180180 expect(h.text()).toContain("you can read this order, not change it");
181181 const before = order();
182 await h.press("j", "t", "n", "d");
182 await h.press("j", "t", "l", "d");
183183 expect(order()).toEqual(before);
184184 h.stop();
185185 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs