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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2800-the-owner-s-home-renders-no-asks-no-for → main 7 commits fork 1 thread · 1 unresolved
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. | ||
| 587 | 587 | The server remembers only the choice — nothing about the state of a laptop — |
| 588 | 588 | and `forge sync` below is what acts on it. |
| 589 | 589 | |
| 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. | |
| 591 | 591 | |
| 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). | |
| 593 | 593 | |
| 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. | |
| 595 | 595 | |
| 596 | 596 | **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). |
| 597 | 597 |
src/web/layout.ts +2 −2
| @@ -8,7 +8,6 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { protectionNotice } from "../kernel/protection.ts"; |
| 11 | import { askCountFor } from "./studio/asks.ts"; | |
| 12 | 11 | import { appsForRepos } from "../core/apps.ts"; |
| 13 | 12 | import { listReposVisibleTo } from "../core/repos.ts"; |
| 14 | 13 | import type { User } from "../core/users.ts"; |
| @@ -460,7 +459,8 @@ function topbar( | ||
| 460 | 459 | <span class="topbar-tools"> |
| 461 | 460 | ${searchBar()} |
| 462 | 461 | ${user |
| 463 | ? html`<span class="hide-sm">${newMenu(ctx)}</span>${userMenu( | |
| 462 | ? html`<a class="small" href="/asks" data-shortcut="g q">Asks</a | |
| 463 | ><span class="hide-sm">${newMenu(ctx)}</span>${userMenu( | |
| 464 | 464 | ctx, |
| 465 | 465 | user, |
| 466 | 466 | theme, |
src/web/pages/site.ts +1 −2
| @@ -24,7 +24,6 @@ import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users | ||
| 24 | 24 | import { isThemeName } from "../../design/themes.ts"; |
| 25 | 25 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 26 | 26 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 27 | import { homeTop } from "../studio/asks_page.ts"; | |
| 28 | 27 | import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; |
| 29 | 28 | import { sshCommand } from "../../ssh/command.ts"; |
| 30 | 29 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| @@ -208,7 +207,7 @@ export function terminalCard(ctx: Ctx): Html { | ||
| 208 | 207 | export async function home(ctx: Ctx): Promise<Response> { |
| 209 | 208 | const user = ctx.auth.user; |
| 210 | 209 | if (user === undefined) return studioDoor(ctx); |
| 211 | return await studioHome(ctx, user, homeTop(ctx)); | |
| 210 | return await studioHome(ctx, user); | |
| 212 | 211 | } |
| 213 | 212 | |
| 214 | 213 | // ---- authentication ---- |
src/web/studio/home.ts +4 −6
| @@ -45,7 +45,7 @@ import { | ||
| 45 | 45 | terminalCard, |
| 46 | 46 | } from "../pages/site.ts"; |
| 47 | 47 | import { writableAppsOf } from "./asks.ts"; |
| 48 | import { needsYouSection, type AsksTop } from "./asks_page.ts"; | |
| 48 | import { needsYouSection } from "./asks_page.ts"; | |
| 49 | 49 | import { flowCards } from "./flow_card.ts"; |
| 50 | 50 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 51 | 51 | import { homeTabs } from "./home_tabs.ts"; |
| @@ -179,7 +179,7 @@ export function liveAppsFragment(ctx: Ctx): Response { | ||
| 179 | 179 | |
| 180 | 180 | /** |
| 181 | 181 | * 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. | |
| 183 | 183 | */ |
| 184 | 184 | export function onYouAnswer(ctx: Ctx, user: User, note?: string): Response { |
| 185 | 185 | return fragment(html`<div>${onYouRegion(ctx, user, note)}</div>`); |
| @@ -205,9 +205,7 @@ export function liveOnYouFragment(ctx: Ctx): Response { | ||
| 205 | 205 | export async function studioHome( |
| 206 | 206 | ctx: Ctx, |
| 207 | 207 | user: User, |
| 208 | top?: AsksTop, | |
| 209 | 208 | ): Promise<Response> { |
| 210 | const needsYou = needsYouSection(ctx, user, top); | |
| 211 | 209 | const { repos: appRepos } = writableAppsOf(ctx, user); |
| 212 | 210 | // A spawned forge says where it lives: the address its person shares. |
| 213 | 211 | const spawned = await spawnedOrg(ctx.app); |
| @@ -256,7 +254,7 @@ export async function studioHome( | ||
| 256 | 254 | ${homeTabs("feed")} |
| 257 | 255 | <div class="dash"> |
| 258 | 256 | <div class="dash-main stack"> |
| 259 | ${onYouRegion(ctx, user, undefined, needsYou.body)} ${composer(ctx, user)} | |
| 257 | ${composer(ctx, user)} | |
| 260 | 258 | <div class="bar feed-head"> |
| 261 | 259 | <span class="spacer"></span> |
| 262 | 260 | <a class="small muted" href="/asked">What you asked for</a> |
| @@ -281,7 +279,7 @@ export async function studioHome( | ||
| 281 | 279 | ctx, |
| 282 | 280 | title: "Home", |
| 283 | 281 | body, |
| 284 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}\n${needsYou.scriptJs}${ask ? `\n${passkeyJs}` : ""}`, | |
| 282 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`, | |
| 285 | 283 | }); |
| 286 | 284 | } |
| 287 | 285 |
src/web/style/base.ts +1
| @@ -118,6 +118,7 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; } | ||
| 118 | 118 | .wordmark:hover .mark { transform: rotate(135deg); } |
| 119 | 119 | .topbar .spacer { flex: 1; } |
| 120 | 120 | .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; } | |
| 121 | 122 | /* The page never scrolls sideways. A strip that must — the tabs, a code |
| 122 | 123 | table — scrolls inside its own box; nothing else may push the page wide. |
| 123 | 124 | 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", () => { | ||
| 142 | 142 | }; |
| 143 | 143 | |
| 144 | 144 | const home = async () => { |
| 145 | const response = await fetch(`${servers.url}/`, { headers: { cookie } }); | |
| 145 | const response = await fetch(`${servers.url}/asks`, { headers: { cookie } }); | |
| 146 | 146 | expect(response.status).toBe(200); |
| 147 | 147 | return response.text(); |
| 148 | 148 | }; |
test/asks-waiting-for.test.ts +2 −2
| @@ -121,7 +121,7 @@ describe("the word on a row", () => { | ||
| 121 | 121 | await rm(dataDir, { recursive: true, force: true }); |
| 122 | 122 | }); |
| 123 | 123 | |
| 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 () => { | |
| 125 | 125 | const lines = askLinesFor(app.db, alice); |
| 126 | 126 | expect(lines).toHaveLength(1); |
| 127 | 127 | expect(lines[0]!.waitingFor).toEqual({ word: "ready to land", on: "you" }); |
| @@ -150,7 +150,7 @@ describe("the word on a row", () => { | ||
| 150 | 150 | expect(text).toContain("ready to land"); |
| 151 | 151 | expect(text).toContain("waitingFor"); |
| 152 | 152 | |
| 153 | const page = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 153 | const page = await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text(); | |
| 154 | 154 | expect(page).toContain('data-waiting="ready to land"'); |
| 155 | 155 | expect(page).toContain('<span class="sr-only">waiting for </span>ready to land'); |
| 156 | 156 | 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", () => { | ||
| 81 | 81 | redirect: "manual", |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | const home = async (path = "/"): Promise<string> => | |
| 84 | const home = async (path = "/asks"): Promise<string> => | |
| 85 | 85 | (await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text()).replace(/\s+/g, " "); |
| 86 | 86 | |
| 87 | 87 | beforeAll(async () => { |
test/home-feed.test.ts +33 −2
| @@ -365,13 +365,44 @@ describe("home is the feed", () => { | ||
| 365 | 365 | expect(second.items.map((i: any) => i.id)).toEqual(whole.slice(2, 4)); |
| 366 | 366 | }); |
| 367 | 367 | |
| 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 | ||
| 368 | 399 | test("The one list can be cleared in one move: a box each, select all, by kind and by repository, one form", async () => { |
| 369 | 400 | const one = openIssue(app, alice, garden, { title: "Sweep the path" }); |
| 370 | 401 | const two = openIssue(app, alice, vault, { title: "Oil the hinges" }); |
| 371 | 402 | if (!one.ok || !two.ok) throw new Error("issues"); |
| 372 | 403 | for (const [repo, issue] of [[garden, one.value], [vault, two.value]] as const) |
| 373 | 404 | 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(); | |
| 375 | 406 | const card = text.slice(text.indexOf('id="live-on-you"')).replace(/\s+/g, " "); |
| 376 | 407 | expect(card).toContain("things need you"); |
| 377 | 408 | expect(card).toContain("assigned to you"); |
| @@ -389,7 +420,7 @@ describe("home is the feed", () => { | ||
| 389 | 420 | expect(text).toContain("fleet.operated"); |
| 390 | 421 | expect(text).toContain("data-pick"); |
| 391 | 422 | // 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(); | |
| 393 | 424 | expect(bobCard).not.toContain('id="asks-bulk"'); |
| 394 | 425 | }); |
| 395 | 426 | }); |
test/live-pages.test.ts +2 −2
| @@ -130,7 +130,7 @@ describe("live browsing pages", () => { | ||
| 130 | 130 | expect(refreshApp).toContain("announceSwap"); |
| 131 | 131 | }); |
| 132 | 132 | |
| 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 () => { | |
| 134 | 134 | const signedOut = await ( |
| 135 | 135 | await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } }) |
| 136 | 136 | ).text(); |
| @@ -161,7 +161,7 @@ describe("live browsing pages", () => { | ||
| 161 | 161 | }) |
| 162 | 162 | ).text(); |
| 163 | 163 | expect(html).toContain('id="live-apps"'); |
| 164 | expect(html).toContain('id="live-on-you"'); | |
| 164 | expect(html).not.toContain('id="live-on-you"'); | |
| 165 | 165 | expect(html).toContain("new EventSource('/events')"); |
| 166 | 166 | }); |
| 167 | 167 |
test/proposal-card.test.ts +1 −1
| @@ -156,7 +156,7 @@ describe("a wave proposal is one inbox card", () => { | ||
| 156 | 156 | test("home shows the Redirect box on the card, and Later's way back", async () => { |
| 157 | 157 | const id = propose("Home wave"); |
| 158 | 158 | 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()); | |
| 160 | 160 | expect(await get()).toContain(`/asks/proposal/${id}/redirect`); |
| 161 | 161 | await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/"]]); |
| 162 | 162 | const page = await get(); |
test/studio-door.test.ts +3 −4
| @@ -606,16 +606,15 @@ describe("the studio home keeps itself current", () => { | ||
| 606 | 606 | await rm(dataDir, { recursive: true, force: true }); |
| 607 | 607 | }); |
| 608 | 608 | |
| 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 () => { | |
| 610 | 610 | const res = await get(); |
| 611 | 611 | expect(res.status).toBe(200); |
| 612 | 612 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); |
| 613 | 613 | expect(res.headers.get("referrer-policy")).toBe("same-origin"); |
| 614 | 614 | const home = await res.text(); |
| 615 | 615 | 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"); | |
| 617 | 617 | expect(home).toContain("new EventSource('/events')"); |
| 618 | expect(home).toContain("Write the README"); | |
| 619 | 618 | }); |
| 620 | 619 | |
| 621 | 620 | 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", () => { | ||
| 628 | 627 | 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 () => { |
| 629 | 628 | const page = await (await get()).text(); |
| 630 | 629 | 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"); | |
| 632 | 631 | |
| 633 | 632 | const headers = { cookie: `${cookie}; forge_mode=studio` }; |
| 634 | 633 | const apps = await fetch(`${base}/live/apps`, { headers }); |
new above
offline
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.