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

The owner's home renders no asks, no for-you list and no count #2848

Merged opened this

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

Merged

scout/forge:scout/2800-the-owner-s-home-renders-no-asks-no-for → main 7 commits fork 1 thread · 1 unresolved

  • Merged. scout/2800-the-owner-s-home-renders-no-asks-no-for was merged into main as 9299dd5.

Review

Sign in to review this change.
12 files changed +54 −26
README.md +3 −3
@@ -587,11 +587,11 @@ my machine", and the dashboard grows an **On your machine** card listing them.
587587 The server remembers only the choice — nothing about the state of a laptop —
588588 and `forge sync` below is what acts on it.
589589
590 **What an ask row waits for.** Every ask row carries one word, `waitingFor` (`src/core/waiting_for.ts`, computed once), and who it waits on, e.g. "ready to land · on you". The first that applies wins: `conflicts with main` (the last sweep found files stopping the pull, on its author), `ready to land` (a green change to look at and ship), `a review` (a review or a hand-over), `assigned to you` (the pull or issue is assigned to the viewer), otherwise `a decision`. It shows as a chip on each row of the home, as `waitingFor: {word, on}` in `GET /api/v1/asks` and the MCP `asks` tool, and in the terminal's On you card. A grouped row shares one word (the weightiest of its items), and the terminal's card shows its row's word.
590 **What an ask row waits for.** Every ask row carries one word, `waitingFor` (`src/core/waiting_for.ts`, computed once), and who it waits on, e.g. "ready to land · on you". The first that applies wins: `conflicts with main` (the last sweep found files stopping the pull, on its author), `ready to land` (a green change to look at and ship), `a review` (a review or a hand-over), `assigned to you` (the pull or issue is assigned to the viewer), otherwise `a decision`. It shows as a chip on each row of `/asks`, as `waitingFor: {word, on}` in `GET /api/v1/asks` and the MCP `asks` tool, and in the terminal's On you card. A grouped row shares one word (the weightiest of its items), and the terminal's card shows its row's word.
591591
592 **Muting a repository from the home**: each ask row on the home carries a "Mute owner/name" chip. A muted repository's asks leave the home, `/api/v1/asks`, the header count and the phone's pushes (the same per-user mute the On you card uses, no new table); the home says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them.
592 **Muting a repository from /asks**: each ask row on `/asks` carries a "Mute owner/name" chip. A muted repository's asks leave `/asks`, `/api/v1/asks` and the phone's pushes (the same per-user mute the On you card uses, no new table); `/asks` says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them. The home itself renders no asks, no for-you list and no count: the top bar carries a plain Asks link to `/asks`, and the terminal's dashboard keeps its On you card on purpose (a different door).
593593
594 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (home, `/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
594 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (`/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks on `/asks` (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
595595
596596 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back. The `feed_chips` tool reads them (no arguments, any token) and switches one (`kind`, `value`, `hidden`; an `rw` token).
597597
src/web/layout.ts +2 −2
@@ -8,7 +8,6 @@
88 */
99
1010 import { protectionNotice } from "../kernel/protection.ts";
11 import { askCountFor } from "./studio/asks.ts";
1211 import { appsForRepos } from "../core/apps.ts";
1312 import { listReposVisibleTo } from "../core/repos.ts";
1413 import type { User } from "../core/users.ts";
@@ -460,7 +459,8 @@ function topbar(
460459 <span class="topbar-tools">
461460 ${searchBar()}
462461 ${user
463 ? html`<span class="hide-sm">${newMenu(ctx)}</span>${userMenu(
462 ? html`<a class="small" href="/asks" data-shortcut="g q">Asks</a

Check this at 360px next to search and the user menu. The link is always visible, so it may squeeze the tools row. If it wraps, hide the link or the search label under the small breakpoint.

463 ><span class="hide-sm">${newMenu(ctx)}</span>${userMenu(
464464 ctx,
465465 user,
466466 theme,
src/web/pages/site.ts +1 −2
@@ -24,7 +24,6 @@ import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users
2424 import { isThemeName } from "../../design/themes.ts";
2525 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2626 import { studioDoor, studioHome } from "../studio/home.ts";
27 import { homeTop } from "../studio/asks_page.ts";
2827 import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
2928 import { sshCommand } from "../../ssh/command.ts";
3029 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
@@ -208,7 +207,7 @@ export function terminalCard(ctx: Ctx): Html {
208207 export async function home(ctx: Ctx): Promise<Response> {
209208 const user = ctx.auth.user;
210209 if (user === undefined) return studioDoor(ctx);
211 return await studioHome(ctx, user, homeTop(ctx));
210 return await studioHome(ctx, user);
212211 }
213212
214213 // ---- authentication ----
src/web/studio/home.ts +4 −6
@@ -45,7 +45,7 @@ import {
4545 terminalCard,
4646 } from "../pages/site.ts";
4747 import { writableAppsOf } from "./asks.ts";
48 import { needsYouSection, type AsksTop } from "./asks_page.ts";
48 import { needsYouSection } from "./asks_page.ts";
4949 import { flowCards } from "./flow_card.ts";
5050 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
5151 import { homeTabs } from "./home_tabs.ts";
@@ -179,7 +179,7 @@ export function liveAppsFragment(ctx: Ctx): Response {
179179
180180 /**
181181 * What a bulk step on the On you list answers its own script with: the
182 * region as it is now and the top bar's Asks link with its new count.
182 * region as it is now and the asks page's list.
183183 */
184184 export function onYouAnswer(ctx: Ctx, user: User, note?: string): Response {
185185 return fragment(html`<div>${onYouRegion(ctx, user, note)}</div>`);
@@ -205,9 +205,7 @@ export function liveOnYouFragment(ctx: Ctx): Response {
205205 export async function studioHome(
206206 ctx: Ctx,
207207 user: User,
208 top?: AsksTop,
209208 ): Promise<Response> {
210 const needsYou = needsYouSection(ctx, user, top);
211209 const { repos: appRepos } = writableAppsOf(ctx, user);
212210 // A spawned forge says where it lives: the address its person shares.
213211 const spawned = await spawnedOrg(ctx.app);
@@ -256,7 +254,7 @@ export async function studioHome(
256254 ${homeTabs("feed")}
257255 <div class="dash">
258256 <div class="dash-main stack">
259 ${onYouRegion(ctx, user, undefined, needsYou.body)} ${composer(ctx, user)}
257 ${composer(ctx, user)}
260258 <div class="bar feed-head">
261259 <span class="spacer"></span>
262260 <a class="small muted" href="/asked">What you asked for</a>
@@ -281,7 +279,7 @@ export async function studioHome(
281279 ctx,
282280 title: "Home",
283281 body,
284 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}${ask ? `\n${passkeyJs}` : ""}`,
282 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`,
285283 });
286284 }
287285
src/web/style/base.ts +1
@@ -118,6 +118,7 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; }
118118 .wordmark:hover .mark { transform: rotate(135deg); }
119119 .topbar .spacer { flex: 1; }
120120 .topbar-tools { display: flex; align-items: center; gap: 6px; flex: 0 1 auto; min-width: 0; }
121 .topbar-tools > a.small { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; flex: none; }
121122 /* The page never scrolls sideways. A strip that must — the tabs, a code
122123 table — scrolls inside its own box; nothing else may push the page wide.
123124 clip, not hidden: hidden would make the body a scroll container and
test/asks-counts.test.ts +1 −1
@@ -142,7 +142,7 @@ describe("home's asks are read grouped, and once", () => {
142142 };
143143
144144 const home = async () => {
145 const response = await fetch(`${servers.url}/`, { headers: { cookie } });
145 const response = await fetch(`${servers.url}/asks`, { headers: { cookie } });
146146 expect(response.status).toBe(200);
147147 return response.text();
148148 };
test/asks-waiting-for.test.ts +2 −2
@@ -121,7 +121,7 @@ describe("the word on a row", () => {
121121 await rm(dataDir, { recursive: true, force: true });
122122 });
123123
124 test("the home, /api/v1/asks and MCP give the same word for the same row", async () => {
124 test("the asks page, /api/v1/asks and MCP give the same word for the same row", async () => {
125125 const lines = askLinesFor(app.db, alice);
126126 expect(lines).toHaveLength(1);
127127 expect(lines[0]!.waitingFor).toEqual({ word: "ready to land", on: "you" });
@@ -150,7 +150,7 @@ describe("the word on a row", () => {
150150 expect(text).toContain("ready to land");
151151 expect(text).toContain("waitingFor");
152152
153 const page = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
153 const page = await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text();
154154 expect(page).toContain('data-waiting="ready to land"');
155155 expect(page).toContain('<span class="sr-only">waiting for </span>ready to land');
156156 expect(page).toContain("ready to land · on you");
test/hand-decider.test.ts +1 −1
@@ -81,7 +81,7 @@ describe("let the decider answer a hand-over", () => {
8181 redirect: "manual",
8282 });
8383
84 const home = async (path = "/"): Promise<string> =>
84 const home = async (path = "/asks"): Promise<string> =>
8585 (await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
8686
8787 beforeAll(async () => {
test/home-feed.test.ts +33 −2
@@ -365,13 +365,44 @@ describe("home is the feed", () => {
365365 expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4));
366366 });
367367
368 test("six open asks fill /asks and leave the home bare: no titles, no section, no count", async () => {
369 const titles = ["Ask alpha", "Ask bravo", "Ask charlie", "Ask delta", "Ask echo", "Ask foxtrot"];
370 for (const title of titles) {
371 const made = openIssue(app, bob, garden, { title });
372 if (!made.ok) throw new Error("issue");
373 expect(assignIssue(app, alice, garden, made.value, alice).ok).toBe(true);
374 }
375 const page = await (await get("/", aliceCookie)).text();
376 // The timeline may still tell of a filed issue as a post; the asks are
377 // what must be gone, so the page is read up to the timeline.
378 const home = page.slice(0, page.indexOf('id="live-feed"'));
379 expect(home.length).toBeGreaterThan(0);
380 for (const title of titles) expect(home).not.toContain(title);
381 expect(page).not.toContain("live-on-you");
382 expect(home).not.toContain("things need you");
383 expect(home).not.toContain("data-pick");
384 expect(home).toContain('href="/asks"');
385 const link = home.match(/<a[^>]*href="\/asks"[^>]*>([^<]*)<\/a\s*>/);
386 expect(link).not.toBeNull();
387 expect(link![1]!.trim()).toBe("Asks");
388 // Nothing after the link, up to the next tag, is a number.
389 const after = home.slice(home.indexOf(link![0]) + link![0].length);
390 expect(after.slice(0, after.indexOf("<")).trim()).not.toMatch(/\d/);
391 // The Asks chord is its own: no two elements on the page share one.
392 const chords = [...page.matchAll(/data-shortcut="([^"]+)"/g)].map((m) => m[1]);
393 expect(chords).toContain("g q");
394 expect(new Set(chords).size).toBe(chords.length);
395 const asks = await (await get("/asks", aliceCookie)).text();
396 for (const title of titles) expect(asks).toContain(title);
397 });
398
368399 test("The one list can be cleared in one move: a box each, select all, by kind and by repository, one form", async () => {
369400 const one = openIssue(app, alice, garden, { title: "Sweep the path" });
370401 const two = openIssue(app, alice, vault, { title: "Oil the hinges" });
371402 if (!one.ok || !two.ok) throw new Error("issues");
372403 for (const [repo, issue] of [[garden, one.value], [vault, two.value]] as const)
373404 expect(assignIssue(app, alice, repo, issue, alice).ok).toBe(true);
374 const text = await (await get("/", aliceCookie)).text();
405 const text = await (await get("/asks", aliceCookie)).text();
375406 const card = text.slice(text.indexOf('id="live-on-you"')).replace(/\s+/g, " ");
376407 expect(card).toContain("things need you");
377408 expect(card).toContain("assigned to you");
@@ -389,7 +420,7 @@ describe("home is the feed", () => {
389420 expect(text).toContain("fleet.operated");
390421 expect(text).toContain("data-pick");
391422 // Alone, a single row stays a plain link.
392 const bobCard = await (await get("/", bobCookie)).text();
423 const bobCard = await (await get("/asks", bobCookie)).text();
393424 expect(bobCard).not.toContain('id="asks-bulk"');
394425 });
395426 });
test/live-pages.test.ts +2 −2
@@ -130,7 +130,7 @@ describe("live browsing pages", () => {
130130 expect(refreshApp).toContain("announceSwap");
131131 });
132132
133 test("the studio home wires up asks, apps, now and on-you as live regions, signed in; a signed-out visitor gets neither", async () => {
133 test("the studio home wires up apps and now as live regions (the asks are a page of their own), signed in; a signed-out visitor gets neither", async () => {
134134 const signedOut = await (
135135 await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } })
136136 ).text();
@@ -161,7 +161,7 @@ describe("live browsing pages", () => {
161161 })
162162 ).text();
163163 expect(html).toContain('id="live-apps"');
164 expect(html).toContain('id="live-on-you"');
164 expect(html).not.toContain('id="live-on-you"');
165165 expect(html).toContain("new EventSource('/events')");
166166 });
167167
test/proposal-card.test.ts +1 −1
@@ -156,7 +156,7 @@ describe("a wave proposal is one inbox card", () => {
156156 test("home shows the Redirect box on the card, and Later's way back", async () => {
157157 const id = propose("Home wave");
158158 const get = async () =>
159 (await (await fetch(`${servers.url}/`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text());
159 (await (await fetch(`${servers.url}/asks`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text());
160160 expect(await get()).toContain(`/asks/proposal/${id}/redirect`);
161161 await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/"]]);
162162 const page = await get();
test/studio-door.test.ts +3 −4
@@ -606,16 +606,15 @@ describe("the studio home keeps itself current", () => {
606606 await rm(dataDir, { recursive: true, force: true });
607607 });
608608
609 test("Your apps and On you are each a live region, with the security floor's headers", async () => {
609 test("Your apps is a live region and the home has no On you, with the security floor's headers", async () => {
610610 const res = await get();
611611 expect(res.status).toBe(200);
612612 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
613613 expect(res.headers.get("referrer-policy")).toBe("same-origin");
614614 const home = await res.text();
615615 expect(home).toMatch(/id="live-apps"\s*\n?\s*data-live="/);
616 expect(home).toMatch(/id="live-on-you"\s*\n?\s*data-live="/);
616 expect(home).not.toContain("live-on-you");
617617 expect(home).toContain("new EventSource('/events')");
618 expect(home).toContain("Write the README");
619618 });
620619
621620 test("chips do not touch Your apps: it lists apps, not feed items", async () => {
@@ -628,7 +627,7 @@ describe("the studio home keeps itself current", () => {
628627 test("each region on the home has an address of its own, answering with that region alone and 304 when it has not changed", async () => {
629628 const page = await (await get()).text();
630629 expect(page).toContain('data-live-src="/live/apps"');
631 expect(page).toContain('data-live-src="/live/on-you"');
630 expect(page).not.toContain("/live/on-you");
632631
633632 const headers = { cookie: `${cookie}; forge_mode=studio` };
634633 const apps = await fetch(`${base}/live/apps`, { headers });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs