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

The top bar says how many things ask you, on every page #104

Merged opened this

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

Merged

scout/forge:scout/74-the-top-bar-says-how-many-things-ask-you → main 1 commit fork

  • Merged. scout/74-the-top-bar-says-how-many-things-ask-you was merged into main as 6a1c554.

Review

Sign in to review this change.
8 files changed +408 −68
docs/STUDIO.md +9
@@ -221,6 +221,15 @@ is the same flow as ever. `/?mode=dev` is the repositories list, and — like
221221 the toggle in the top bar — it switches this browser to dev from then on;
222222 `/?mode=studio` switches it back, the same way.
223223
224 The top bar carries the same count, on every studio page: a pill beside the
225 wordmark, linking here, naming how many things ask the signed-in person —
226 `askCountFor` (`src/web/studio/asks.ts`), the candidates `decisionsFor`
227 itself builds its cards from, before either the words or a preview picture
228 are read, so the two can never disagree. With nothing asking, the bar shows
229 nothing rather than a zero. It is studio's own; dev's top bar never carries
230 it. On a page that already streams (the home, an app, the map, the board)
231 the bar's own live region rides that same stream — no second connection.
232
224233 The home keeps itself current. *Asks you*, *your apps*, *happening now* and
225234 *on you* are each a live region (`data-live`, `src/web/client.ts`): a deploy
226235 serving, a pull opening, closing or merging, a run starting or ending, or an
src/crew/autonomy.ts +33
@@ -119,6 +119,39 @@ export function spentToday(
119119 );
120120 }
121121
122 /**
123 * Agent spend since UTC midnight for many repositories at once, keyed by
124 * repo id — one query, for a count that must not slow down as the apps
125 * grow (the topbar's ask count is the reason this exists).
126 */
127 export function spentTodayForRepos(
128 db: App["db"],
129 repoIds: readonly string[],
130 now = new Date(),
131 ): Map<string, number> {
132 const out = new Map<string, number>();
133 const unique = [...new Set(repoIds)];
134 if (unique.length === 0) return out;
135 const since = startOfTodayUtc(now).toISOString();
136 const CHUNK = 400;
137 for (let i = 0; i < unique.length; i += CHUNK) {
138 const batch = unique.slice(i, i + CHUNK);
139 const rows = db
140 .query<
141 { repo_id: string; spent: number | null },
142 string[]
143 >(
144 `SELECT repo_id, SUM(cost_usd) AS spent FROM run
145 WHERE kind = 'agent' AND created_at >= ?${batch.length + 1}
146 AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
147 GROUP BY repo_id`,
148 )
149 .all(...batch, since);
150 for (const row of rows) out.set(row.repo_id, row.spent ?? 0);
151 }
152 return out;
153 }
154
122155 function activeRuns(app: App, repoId: string) {
123156 return listActive(app.db, "agent").filter((r) => r.repoId === repoId);
124157 }
src/web/layout.ts +31 −1
@@ -29,7 +29,8 @@ import {
2929 import { prefersJson } from "./form.ts";
3030 import { html, raw, when, type Html } from "./html.ts";
3131 import { icon, themeGlyph } from "./icons.ts";
32 import { btn, iconBtn, kbd, menu, type MenuItem } from "./ui.ts";
32 import { appsOf, askCountFor } from "./studio/asks.ts";
33 import { btn, iconBtn, kbd, menu, pill, type MenuItem } from "./ui.ts";
3334
3435 /** One row of the command palette: title, href, category. */
3536 export interface PaletteItem {
@@ -142,6 +143,34 @@ function modeToggle(ctx: Ctx): Html {
142143 </span>`;
143144 }
144145
146 /**
147 * What asks the signed-in person, across every app they can land — the
148 * same set the home's own "Asks you" panel counts (`askCountFor`, the
149 * cheap query beside `decisionsFor`), never built from the cards
150 * themselves. Studio only; dev's top bar never carries it. With nothing
151 * asking, the wrapper stays in the document — so a live swap on a page
152 * that already streams (the home, an app, the map, the board) can still
153 * find it by id and fill it in — but nothing renders inside it: a zero is
154 * not a fact worth a badge.
155 */
156 function asksBadge(ctx: Ctx): Html {
157 const user = ctx.auth.user;
158 if (!user || ctx.mode !== "studio") return html``;
159 const count = askCountFor(ctx, appsOf(ctx, user).repos);
160 return html`<span id="topbar-asks" data-live="run.*,pull.*,issue.*,check.*"
161 >${when(
162 count,
163 (n) =>
164 html`<a
165 class="topbar-asks"
166 href="/"
167 title="${n} thing${n === 1 ? "" : "s"} ask you"
168 >${pill(`${n} asks you`, "accent")}</a
169 >`,
170 )}</span
171 >`;
172 }
173
145174 function newMenu(): Html {
146175 const items: MenuItem[] = [
147176 { label: "New app", href: "/apps/new", icon: "rocket" },
@@ -353,6 +382,7 @@ function topbar(
353382 <a href="/" class="wordmark" data-shortcut="g h"
354383 ><span class="mark"></span>forge</a
355384 >
385 ${asksBadge(ctx)}
356386 ${when(
357387 crumbs,
358388 (items) =>
src/web/studio/actions.ts +1 −2
@@ -23,8 +23,7 @@ import {
2323 import { field, readForm } from "../form.ts";
2424 import { flashOrJson, flashTo, redirect } from "../layout.ts";
2525 import { isMode, modeCookie } from "../mode.ts";
26 import { BULK, decisionsFor, isBulkAction, takes } from "./asks.ts";
27 import { appsOf } from "./home.ts";
26 import { appsOf, BULK, decisionsFor, isBulkAction, takes } from "./asks.ts";
2827
2928 /** A merge refusal in a person's words. */
3029 function mergeWords(error: unknown): string {
src/web/studio/asks.ts +131 −52
@@ -8,6 +8,7 @@
88 */
99
1010 import { previewEnv } from "../../apps/data.ts";
11 import { crewForRepos } from "../../core/agents.ts";
1112 import { appsForRepos, getAppByRepo } from "../../core/apps.ts";
1213 import { latestDecision } from "../../core/decisions.ts";
1314 import {
@@ -17,14 +18,15 @@ import {
1718 } from "../../core/issues.ts";
1819 import { looksForApps } from "../../core/looks.ts";
1920 import { joinMany, type Pull } from "../../core/pulls.ts";
20 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
21 import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts";
2122 import { listReviews } from "../../core/reviews.ts";
2223 import {
2324 activeRunFor,
2425 automaticRunsSince,
2526 latestRunsForIssues,
2627 } from "../../core/runs.ts";
27 import { autonomyStatus } from "../../crew/autonomy.ts";
28 import type { User } from "../../core/users.ts";
29 import { spentTodayForRepos } from "../../crew/autonomy.ts";
2830 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
2931 import type { Ctx } from "../../http/context.ts";
3032 import { repoBase } from "../chrome.ts";
@@ -33,6 +35,16 @@ import { btn, card, icon, row } from "../ui.ts";
3335 import { lookThumb, lookUrl } from "../pages/looks.ts";
3436 import { leanNote, leanVariant } from "./leans.ts";
3537
38 /** The apps a person can see, as repositories, with their records. */
39 export function appsOf(ctx: Ctx, user: User) {
40 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
41 const records = appsForRepos(
42 ctx.app.db,
43 repos.map((r) => r.id),
44 );
45 return { repos: repos.filter((r) => records.has(r.id)), records };
46 }
47
3648
3749 export type DecisionKind = "stopped" | "look" | "paused";
3850
@@ -69,14 +81,37 @@ function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string {
6981 return latest;
7082 }
7183
84 /**
85 * A candidate ask, classified but not yet worded or pictured: the set
86 * `decisionsFor` and `askCountFor` both build from, so a count can never
87 * disagree with the list it is a count of.
88 */
89 interface AskCandidate {
90 readonly kind: DecisionKind;
91 readonly repo: Repo;
92 readonly pull?: Pull;
93 readonly who: string;
94 /** For "stopped": how many automatic tries, and whether the decider itself stopped it. */
95 readonly tries?: number;
96 readonly stoppedByDecider?: boolean;
97 }
98
7299 /**
73100 * The crew's open pulls the viewer may land, sorted into what asks them:
74101 * stopped (red past the crew's own limit since a person last spoke), or
75102 * green and waiting for a look. Plus a repository paused at its ceiling.
103 * Every read here is bounded by the pulls a person can see or the
104 * repositories handed in — never by a loop that queries once per app: the
105 * ceiling check reads spend and crew for every repository in one query
106 * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per
107 * repository.
76108 */
77 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
109 function askCandidatesFor(
110 ctx: Ctx,
111 allRepos: readonly Repo[],
112 ): AskCandidate[] {
78113 const db = ctx.app.db;
79 const out: Decision[] = [];
114 const out: AskCandidate[] = [];
80115 const user = ctx.auth.user;
81116 if (!user) return out;
82117 // Shipping, closing and the day's ceiling are the owner's own decisions —
@@ -92,25 +127,6 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
92127 pulls.map((p) => p.id),
93128 );
94129 const joined = new Map(joinMany(db, pulls).map((p) => [p.id, p]));
95 // The pictures, in one read: a green pull's row leads with its preview.
96 const records = appsForRepos(db, [...byId.keys()]);
97 const looks = looksForApps(
98 db,
99 [...records.values()].map((r) => r.id),
100 );
101 const pictureOf = (repo: Repo, pull: Pull): string | undefined => {
102 const record = records.get(repo.id);
103 if (record === undefined) return undefined;
104 const env = previewEnv(pull.number);
105 const look = looks.find(
106 (l) =>
107 l.appId === record.id &&
108 l.env === env &&
109 l.kind === "phone" &&
110 l.error === null,
111 );
112 return look && lookUrl(repoBase(repo), env, look.commitOid, "phone");
113 };
114130 for (const issue of pulls) {
115131 const repo = byId.get(issue.repoId)!;
116132 const pull = joined.get(issue.id);
@@ -131,48 +147,111 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
131147 )
132148 out.push({
133149 kind: "stopped",
134 id: `stopped:${pull.id}`,
135150 repo,
136151 pull,
137152 who,
138 says: stopped
139 ? `${who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.`
140 : `${who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`,
141 short: stopped
142 ? `the check failed; the decider stopped for you`
143 : `${who} tried ${tries + 1} times; the check still fails`,
153 tries,
154 stoppedByDecider: stopped,
144155 });
145156 } else if (check?.state === "ok" && repo.autonomy !== "autonomous") {
146157 // Autonomous repositories look at their own green pulls: the decider,
147158 // then the crew's reviewer. Everything else waits for a person.
148 const picture = pictureOf(repo, pull);
149 out.push({
150 kind: "look",
151 id: `look:${pull.id}`,
152 repo,
153 pull,
154 who,
155 says: `${who} finished "${pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`,
156 short: `${who} finished it; the check is green`,
157 ...(picture === undefined ? {} : { picture }),
158 });
159 out.push({ kind: "look", repo, pull, who });
159160 }
160161 }
162 const crew = crewForRepos(
163 db,
164 repos.map((r) => r.id),
165 );
166 const spent = spentTodayForRepos(
167 db,
168 repos.map((r) => r.id),
169 );
161170 for (const repo of repos) {
162 if (repo.autonomy === "off") continue;
163 const s = autonomyStatus(ctx.app, repo);
164 if (s.paused === "ceiling")
165 out.push({
166 kind: "paused",
167 id: `paused:${repo.id}`,
168 repo,
169 who: "the crew",
170 says: `${repo.name} reached its $${dollars(s.ceilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`,
171 short: `at its $${dollars(s.ceilingUsd)} ceiling until 00:00 UTC`,
172 });
171 // Only a ceiling pause is an ask — "nobody" and "no crew" are settings
172 // problems, not a decision, so neither needs its own grouped read.
173 if (repo.autonomy === "off" || repo.autonomyBy === null) continue;
174 if ((crew.get(repo.id)?.length ?? 0) === 0) continue;
175 if ((spent.get(repo.id) ?? 0) >= repo.autonomyCeilingUsd)
176 out.push({ kind: "paused", repo, who: "the crew" });
173177 }
174178 return out;
175179 }
180
181 /**
182 * What asks the viewer, across every app they can land — the count the
183 * topbar carries. The same candidates `decisionsFor` builds its cards
184 * from, with no picture read and no text built: cheap on every page, not
185 * only the home's.
186 */
187 export function askCountFor(ctx: Ctx, allRepos: readonly Repo[]): number {
188 return askCandidatesFor(ctx, allRepos).length;
189 }
190
191 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
192 const db = ctx.app.db;
193 const candidates = askCandidatesFor(ctx, allRepos);
194 // The pictures, in one read: a green pull's row leads with its preview.
195 const repoIds = [...new Set(candidates.map((c) => c.repo.id))];
196 const records = appsForRepos(db, repoIds);
197 const looks = looksForApps(
198 db,
199 [...records.values()].map((r) => r.id),
200 );
201 const pictureOf = (repo: Repo, pull: Pull): string | undefined => {
202 const record = records.get(repo.id);
203 if (record === undefined) return undefined;
204 const env = previewEnv(pull.number);
205 const look = looks.find(
206 (l) =>
207 l.appId === record.id &&
208 l.env === env &&
209 l.kind === "phone" &&
210 l.error === null,
211 );
212 return look && lookUrl(repoBase(repo), env, look.commitOid, "phone");
213 };
214 return candidates.map((c): Decision => {
215 if (c.kind === "stopped" && c.pull) {
216 const tries = c.tries ?? 0;
217 const stopped = c.stoppedByDecider ?? false;
218 return {
219 kind: "stopped",
220 id: `stopped:${c.pull.id}`,
221 repo: c.repo,
222 pull: c.pull,
223 who: c.who,
224 says: stopped
225 ? `${c.who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.`
226 : `${c.who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`,
227 short: stopped
228 ? `the check failed; the decider stopped for you`
229 : `${c.who} tried ${tries + 1} times; the check still fails`,
230 };
231 }
232 if (c.kind === "look" && c.pull) {
233 const picture = pictureOf(c.repo, c.pull);
234 return {
235 kind: "look",
236 id: `look:${c.pull.id}`,
237 repo: c.repo,
238 pull: c.pull,
239 who: c.who,
240 says: `${c.who} finished "${c.pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`,
241 short: `${c.who} finished it; the check is green`,
242 ...(picture === undefined ? {} : { picture }),
243 };
244 }
245 return {
246 kind: "paused",
247 id: `paused:${c.repo.id}`,
248 repo: c.repo,
249 who: "the crew",
250 says: `${c.repo.name} reached its $${dollars(c.repo.autonomyCeilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`,
251 short: `at its $${dollars(c.repo.autonomyCeilingUsd)} ceiling until 00:00 UTC`,
252 };
253 });
254 }
176255 // ---- deciding several at once ----
177256
178257 /**
src/web/studio/home.ts +2 −13
@@ -8,8 +8,7 @@
88 */
99
1010 import { appsRuntime } from "../../apps/runtime.ts";
11 import { appsForRepos } from "../../core/apps.ts";
12 import { canSeeOwnerFacts, listReposVisibleTo } from "../../core/repos.ts";
11 import { canSeeOwnerFacts } from "../../core/repos.ts";
1312 import { latestAgentRunsForRepos, listActiveRuns } from "../../core/runs.ts";
1413 import { getUsersByIds, type User } from "../../core/users.ts";
1514 import { repoDir } from "../../git/repos.ts";
@@ -32,7 +31,7 @@ import {
3231 type Tone,
3332 } from "../ui.ts";
3433 import { onYouCard } from "../pages/site.ts";
35 import { asksPanel, decisionsFor } from "./asks.ts";
34 import { appsOf, asksPanel, decisionsFor } from "./asks.ts";
3635 import { withLikely } from "./leans.ts";
3736 import { stopForm } from "./run.ts";
3837 import { studioCss } from "./style.ts";
@@ -235,13 +234,3 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
235234 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${asksJs}`,
236235 });
237236 }
238
239 /** The apps a person can see, as repositories, with their records. */
240 export function appsOf(ctx: Ctx, user: User) {
241 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
242 const records = appsForRepos(
243 ctx.app.db,
244 repos.map((r) => r.id),
245 );
246 return { repos: repos.filter((r) => records.has(r.id)), records };
247 }
src/web/style/base.ts +2
@@ -103,6 +103,8 @@ h3 { font-size: 14px; font-weight: 600; line-height: 1.4; }
103103 }
104104 .wordmark:hover { color: var(--fg); }
105105 .wordmark:hover .mark { transform: rotate(135deg); }
106 .topbar-asks { display: inline-flex; flex: none; text-decoration: none; }
107 .topbar-asks:hover .pill { background: color-mix(in srgb, var(--accent) 24%, transparent); }
106108 .topbar .spacer { flex: 1; }
107109 .topbar-tools { display: flex; align-items: center; gap: 6px; flex: none; }
108110
test/studio.test.ts +199
@@ -1274,6 +1274,205 @@ describe("the studio home keeps itself current", () => {
12741274 });
12751275 });
12761276
1277 /**
1278 * The top bar's own count of what asks the signed-in person — carried on
1279 * every studio page, from `askCountFor` (`src/web/studio/asks.ts`), the
1280 * cheap query beside `decisionsFor` the home itself uses.
1281 */
1282 describe("the top bar carries the count of what asks you", () => {
1283 let dataDir: string;
1284 let app: App;
1285 let servers: Servers;
1286 let base: string;
1287 let alice: User;
1288 let repo: Repo;
1289 let aliceCookie: string;
1290 let bobCookie: string;
1291 let carolCookie: string;
1292
1293 const signIn = async (username: string): Promise<string> =>
1294 (
1295 (
1296 await fetch(`${base}/signin`, {
1297 method: "POST",
1298 body: new URLSearchParams({
1299 username,
1300 password: "a-good-passphrase",
1301 }),
1302 redirect: "manual",
1303 })
1304 ).headers.get("set-cookie") ?? ""
1305 ).split(";")[0]!;
1306
1307 const home = (cookie: string, mode: "studio" | "dev" = "studio") =>
1308 fetch(`${base}/`, {
1309 headers: { cookie: `${cookie}; forge_mode=${mode}` },
1310 redirect: "manual",
1311 }).then((r) => r.text());
1312
1313 const askPull = async (
1314 owner: User,
1315 who: User,
1316 repository: Repo,
1317 name: string,
1318 outcome: "ok" | "failed",
1319 ): Promise<Pull> => {
1320 const branch = await commitToBranch(app, owner, repository, {
1321 branch: `ask/${name}`,
1322 base: "main",
1323 message: `Ask ${name}`,
1324 files: [{ path: `${name}.md`, content: `${name}\n` }],
1325 });
1326 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
1327 const opened = await openPull(app, who, repository, {
1328 title: `Ask ${name}`,
1329 body: "",
1330 baseBranch: "main",
1331 headBranch: `ask/${name}`,
1332 });
1333 if (!opened.ok) throw new Error(String(opened.error));
1334 const pull = opened.value;
1335 const run = createRun(app.db, {
1336 repoId: repository.id,
1337 kind: "check",
1338 issueId: pull.id,
1339 env: `pull-${pull.number}`,
1340 commitOid: pull.headOid,
1341 reason: "pull",
1342 });
1343 claimRun(app.db, run.id);
1344 finishRun(app.db, run.id, { state: outcome, summary: `check ${outcome}` });
1345 if (outcome === "failed") {
1346 await Bun.sleep(5);
1347 for (let i = 0; i < CHECK_FAILED_LIMIT; i++) {
1348 const tried = createRun(app.db, {
1349 repoId: repository.id,
1350 kind: "agent",
1351 issueId: pull.id,
1352 reason: "check_failed",
1353 actorId: who.id,
1354 });
1355 claimRun(app.db, tried.id);
1356 finishRun(app.db, tried.id, { state: "failed", summary: "still red" });
1357 }
1358 }
1359 return pull;
1360 };
1361
1362 beforeAll(async () => {
1363 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-topbar-asks-"));
1364 app = createApp({ dataDir, port: 0 });
1365 servers = await serve(app);
1366 base = servers.url;
1367 const a = await createUser(app.db, {
1368 username: "alice",
1369 email: "[email protected]",
1370 password: "a-good-passphrase",
1371 adminIfFirst: true,
1372 });
1373 if (!a.ok) throw new Error(a.error);
1374 alice = a.value;
1375 const b = await createUser(app.db, {
1376 username: "bob",
1377 email: "[email protected]",
1378 password: "a-good-passphrase",
1379 });
1380 if (!b.ok) throw new Error(b.error);
1381 const bob = b.value;
1382 const c = await createUser(app.db, {
1383 username: "carol",
1384 email: "[email protected]",
1385 password: "a-good-passphrase",
1386 });
1387 if (!c.ok) throw new Error(c.error);
1388 aliceCookie = await signIn("alice");
1389 bobCookie = await signIn("bob");
1390 carolCookie = await signIn("carol");
1391
1392 const made = await createRepo(app, alice, {
1393 name: "signals",
1394 visibility: "private",
1395 description: "",
1396 });
1397 if (!made.ok) throw new Error(String(made.error));
1398 repo = made.value;
1399 const seeded = await commitToBranch(app, alice, repo, {
1400 message: "Start",
1401 files: [{ path: "README.md", content: "s\n" }],
1402 });
1403 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1404 const enabled = await enableApp(app, alice, repo, { slug: "signals" });
1405 if (!enabled.ok) throw new Error(String(enabled.error));
1406 const scout = await createAgent(app, alice, { username: "scout" });
1407 if (!scout.ok) throw new Error(String(scout.error));
1408 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
1409 const scoutUser = getUserByName(app.db, "scout")!;
1410
1411 // Bob's own app: two asks of his — a green pull waiting for a look,
1412 // and one stopped past the crew's own limit.
1413 const otherMade = await createRepo(app, bob, {
1414 name: "widget",
1415 visibility: "public",
1416 description: "",
1417 });
1418 if (!otherMade.ok) throw new Error(String(otherMade.error));
1419 const other = otherMade.value;
1420 const otherSeeded = await commitToBranch(app, bob, other, {
1421 message: "Start",
1422 files: [{ path: "README.md", content: "w\n" }],
1423 });
1424 if (!otherSeeded.ok) throw new Error(JSON.stringify(otherSeeded.error));
1425 const otherEnabled = await enableApp(app, bob, other, { slug: "widget" });
1426 if (!otherEnabled.ok) throw new Error(String(otherEnabled.error));
1427 const otherAgent = await createAgent(app, alice, { username: "helper" });
1428 if (!otherAgent.ok) throw new Error(String(otherAgent.error));
1429 expect(addToCrew(app, bob, other, "helper").ok).toBe(true);
1430 const helperUser = getUserByName(app.db, "helper")!;
1431 await askPull(bob, helperUser, other, "widget-look", "ok");
1432 await askPull(bob, helperUser, other, "widget-stopped", "failed");
1433
1434 // A private app of alice's own, with a real ask on it too — never
1435 // bob's to see, let alone count: it is private, and he is on no crew.
1436 await askPull(alice, scoutUser, repo, "signals-look", "ok");
1437 });
1438
1439 afterAll(async () => {
1440 servers.stop();
1441 app.close();
1442 await rm(dataDir, { recursive: true, force: true });
1443 });
1444
1445 test("in studio, the bar carries the count for a person with two asks, linking to the home, and it equals the home's own list length", async () => {
1446 const page = await home(bobCookie);
1447 const picks = page.match(/class="studio-ask-pick"/g)?.length ?? 0;
1448 expect(picks).toBe(2);
1449 expect(page).toContain('id="topbar-asks"');
1450 expect(page).toMatch(
1451 /class="topbar-asks"[\s\S]{0,80}href="\/"[\s\S]{0,120}data-tone="accent"[\s\S]{0,20}>2 asks you</,
1452 );
1453 });
1454
1455 test("the bar never counts an app a person cannot land: alice's own ask on her private app never reaches bob's count", async () => {
1456 // Alice really does have an ask of her own; bob's stays at his own two.
1457 const alicePage = await home(aliceCookie);
1458 expect(alicePage).toMatch(/1 asks you|2 asks you/);
1459 const bobPage = await home(bobCookie);
1460 expect(bobPage).toContain("2 asks you");
1461 expect(bobPage).not.toContain("signals");
1462 });
1463
1464 test("carries nothing for a person with none: the bar shows nothing, not a zero", async () => {
1465 const page = await home(carolCookie);
1466 expect(page).not.toContain('class="topbar-asks"');
1467 expect(page).toMatch(/<span id="topbar-asks" data-live="[^"]*"\s*>\s*<\/span/);
1468 });
1469
1470 test("dev mode's top bar is unchanged: no count, at all, even for a person with asks", async () => {
1471 const page = await home(bobCookie, "dev");
1472 expect(page).not.toContain("topbar-asks");
1473 });
1474 });
1475
12771476 /**
12781477 * The studio head of an app that is not up: the same facts the App page
12791478 * shows — why, the deploy that says the rest, and the way to try again.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs