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

/talk?about= names its subject: one line above the composer, only for what the viewer can read #10614

Merged opened this

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

Merged

mason/forge:mason/10494-talk-about-names-its-subject-one-line-ab → main 6 commits fork 1 thread · 1 unresolved

  • Merged. mason/10494-talk-about-names-its-subject-one-line-ab was merged into main as 0c5169a.

Review

Sign in to review this change.
6 files changed +163 −0
docs/design/areas/talk.md +1
@@ -17,6 +17,7 @@
1717 - `.talk-meta` — who said it and when, with the copy button.
1818 - `.talk-copy` — a quiet text button that copies a message or a code block.
1919 - `.talk-suggest` — the starter questions on an empty chat; each fills the composer.
20 - `.talk-about` — the one line naming the subject (`?about=`), above the composer; no rule of its own: it is plain `small muted` text that wraps like any paragraph.
2021 - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end.
2122 - `.talk-chats` — the conversations list above the chat (#8814): New, the person's conversations newest first, and the Archived filter link.
2223 - `.talk-here` — the line naming the open conversation, with its Rename and Archive forms.
docs/features/talk.md +4
@@ -18,6 +18,10 @@ A reply streams in over `GET /talk/stream?after=<turn id>` (server-sent events,
1818
1919 When the agent asks first (`needs_confirm`), the turn draws **Confirm** (the existing yes) and **Cancel** (a no: the confirm is spent, nothing runs, a turn says it was cancelled). A result shown once (an invite link) is drawn by Settings → Confirms instead, where Confirm leads.
2020
21 ## Naming the subject: `?about=` and `--about`
22
23 `/talk?about=owner/repo` or `/talk?about=owner/repo#N` (an issue or pull) puts one plain line above the composer: "About owner/repo" or "About owner/repo#N: <title>". The terminal twin is `ssh <forge> talk --about owner/repo[#N] [words]`: it prints the same line first, and with no words prints only that line. Both read one lookup, `aboutLine()` in `src/core/talk_about.ts`. A value that is malformed, names something unknown, or names something you cannot read (a private repository, a sealed issue, signed out) shows nothing and the page is byte-identical to plain `/talk`, so a private repository's existence is not revealed.
24
2125 ## Many conversations (#8814)
2226
2327 A person has any number. `/talk` is their latest, with the list above it (newest activity first), **New**, and **Archived (n)** as the one filter (`/talk?archived=1`). Each opens at `/talk/<id>`, stable across reloads; Rename and Archive/Restore are plain forms (`POST /talk/:id/rename`, `/talk/:id/archive`, `POST /talk/new`), so they work without the script. Another person's id is 404 everywhere. A message with no conversation named goes to the latest one that is not archived.
src/core/talk_about.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What a talk is about: `owner/repo` or `owner/repo#N` (an issue or pull),
3 * read from `/talk?about=` on the page and `talk --about` in the terminal.
4 * One lookup for both doors, and it names nothing the viewer cannot read:
5 * malformed, unknown and unreadable all answer null, so a private
6 * repository's existence is not revealed.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { getIssue } from "./issues.ts";
11 import { canRead, getRepo } from "./repos.ts";
12 import { mayViewId } from "./sealed.ts";
13 import type { User } from "./users.ts";
14
15 /** What `about` names: a repository, and an issue or pull of it when `number` is there. */
16 export interface About {
17 readonly owner: string;
18 readonly repo: string;
19 readonly number?: number;
20 }
21 const ABOUT = /^([A-Za-z0-9][A-Za-z0-9_.-]{0,63})\/([A-Za-z0-9][A-Za-z0-9_.-]{0,63})(?:#([1-9][0-9]{0,8}))?$/;
22
23 /** `owner/repo` or `owner/repo#N` read from text; null for anything else. Pure: it looks nothing up. */
24 export function parseAbout(text: string | null): About | null {
25 const m = text === null || text.length > 140 ? null : ABOUT.exec(text);
26 if (m === null) return null;
27 return m[3] === undefined ? { owner: m[1]!, repo: m[2]! } : { owner: m[1]!, repo: m[2]!, number: Number(m[3]) };
28 }
29
30 /** The plain line "About owner/repo[#N: title]", or null when the text is malformed, unknown or not the viewer's to read. */
31 export function aboutLine(db: Database, text: string | null, viewer: User | undefined): string | null {
32 const about = parseAbout(text);
33 if (about === null) return null;
34 const repo = getRepo(db, about.owner, about.repo);
35 if (repo === undefined || !canRead(db, repo, viewer)) return null;
36 const name = `${about.owner}/${about.repo}`;
37 if (about.number === undefined) return `About ${name}`;
38 const issue = getIssue(db, repo.id, about.number);
39 if (issue === undefined || !mayViewId(db, repo, issue.id, viewer)) return null;
40 return `About ${name}#${about.number}: ${issue.title}`;
41 }
src/tui/ssh-talk.ts +19
@@ -15,6 +15,11 @@
1515 * `talk-again edit <words>`, `talk-again regenerate` and `talk-again retry`
1616 * are the same three actions the page has (#8817), through `talkAgain`.
1717 *
18 * `talk --about owner/repo[#N] [words]` first prints the same one line the
19 * page shows above its composer ("About owner/repo#N: title"), and nothing
20 * when the subject is malformed, unknown or not the person's to read; with
21 * no words it prints only that line.
22 *
1823 * `talk --attach <name> <words>` sends one file with the words: its bytes are
1924 * the exec's standard input (`cat shot.png | ssh <forge> talk --attach
2025 * shot.png look at this`). The forge never opens a path of its own, so the
@@ -26,6 +31,7 @@ import { cancelTalk, queuedTalk, TALK_WORDS, stopTalk, talk, talkAgain } from ".
2631 import { FILE_WORDS, IMAGE_MAX, pendingAmong, removePending, storeFile } from "../core/conversation_files.ts";
2732 import { contextOf, contextText, dollars, turnCosts } from "../core/conversation_cost.ts";
2833 import { openConversation } from "../core/conversations.ts";
34 import { aboutLine } from "../core/talk_about.ts";
2935 import type { App } from "../app.ts";
3036 import type { RedoHow } from "../crew/conversation.ts";
3137 import type { User } from "../core/users.ts";
@@ -58,6 +64,12 @@ export function parseAttach(words: string): { name: string; words: string } | un
5864 return m === null ? undefined : { name: m[1]!, words: m[2] ?? "" };
5965 }
6066
67 /** `--about <ref> <words>`: the subject named and the words after it; undefined without the flag. */
68 export function parseAbout(words: string): { about: string; words: string } | undefined {
69 const m = /^--about[ \t]+(\S+)(?:[ \t]+([\s\S]*))?$/.exec(words.trim());
70 return m === null ? undefined : { about: m[1]!, words: m[2] ?? "" };
71 }
72
6173 /** Standard input to its end, or `too_large` past `max` bytes. */
6274 function readStdin(session: SshSession, max: number): Promise<Uint8Array | "too_large"> {
6375 return new Promise((resolve) => {
@@ -98,6 +110,13 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined,
98110 session.write(`cancelled${CRLF}`);
99111 return session.exit(0);
100112 }
113 const subject = parseAbout(words);
114 if (subject !== undefined) {
115 const line = aboutLine(app.db, subject.about, user);
116 if (line !== null) session.write(`${line.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, " ")}${CRLF}`);
117 if (subject.words.trim() === "") return session.exit(0);
118 words = subject.words;
119 }
101120 const during = parseDuring(words);
102121 if (during !== undefined) words = during.words;
103122 if (isStop(words)) {
src/web/pages/talk.ts +8
@@ -38,6 +38,7 @@ import {
3838 type Turn,
3939 } from "../../core/conversations.ts";
4040 import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts";
41 import { aboutLine as aboutText } from "../../core/talk_about.ts";
4142 import { WORDS_MAX } from "../../core/fence.ts";
4243 import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts";
4344 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -70,6 +71,12 @@ const HERE = "/talk";
7071 const FIND_CHARS = 100;
7172 const CONVERSATION_ID = /^[0-9a-z]{1,40}$/;
7273
74 /** The one line naming what the talk is about (`core/talk_about.ts`), or nothing. */
75 function aboutLine(ctx: Ctx): Html | null {
76 const line = aboutText(ctx.app.db, ctx.url.searchParams.get("about"), ctx.auth.user);
77 return line === null ? null : html`<p class="talk-about small muted" data-about>${line}</p>`;
78 }
79
7380 /** What a conversation is called in a list: its title, else the day it began. */
7481 const nameOf = (c: Conversation): string => (c.title === "" ? `Chat of ${c.createdAt.slice(0, 10)}` : c.title);
7582 /** Where a conversation lives. */
@@ -316,6 +323,7 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi
316323 </ol>
317324 <div id="end"></div>
318325 </div>
326 ${aboutLine(ctx)}
319327 <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? here : `${here}/again/edit`}" ${editing === undefined ? null : html`data-edit`}>
320328 ${conversation.archivedAt === null ? null : html`<p class="small muted" data-archived-note>Archived: sending here keeps it archived; Restore puts it back in the list.</p>`}
321329 <p class="talk-status small muted" role="status" aria-live="polite">${running ? "The forge is answering…" : ""}</p>
test/talk-page.test.ts +90
@@ -12,7 +12,10 @@ import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
1313 import { createRepo } from "../src/actions/repos.ts";
1414 import { sayNo } from "../src/actions/conversation_confirms.ts";
15 import { openIssue } from "../src/actions/issues.ts";
1516 import { clock, STREAMS_MAX } from "../src/web/pages/talk.ts";
17 import { serveTalk } from "../src/tui/ssh-talk.ts";
18 import { aboutLine, parseAbout } from "../src/core/talk_about.ts";
1619 import { appendTurn, listTurns, openConversation } from "../src/core/conversations.ts";
1720 import { hold, waitingFor } from "../src/core/fence.ts";
1821 import { getRepoById } from "../src/core/repos.ts";
@@ -662,3 +665,90 @@ describe("cost and context (#8822)", () => {
662665 expect(calm).not.toContain("A new conversation answers faster");
663666 });
664667 });
668
669 describe("?about= names the subject", () => {
670 const subject = (page: string) => /<p class="talk-about[^>]*>[^<]*<\/p>/.exec(page)?.[0];
671 test("parseAbout reads owner/repo and owner/repo#N, and nothing else", () => {
672 expect(parseAbout("a/b")).toEqual({ owner: "a", repo: "b" });
673 expect(parseAbout("a/b#12")).toEqual({ owner: "a", repo: "b", number: 12 });
674 for (const bad of ["../x", "a/b#x", "a/b#0", "a/b/c", "", "x".repeat(300), "a/b#1 "]) expect(parseAbout(bad)).toBeNull();
675 expect(parseAbout(null)).toBeNull();
676 });
677
678 test("a readable issue shows its title, escaped; a repository shows its name", async () => {
679 const made = await createRepo(app, alice, { name: "about-pub", visibility: "public" });
680 if (!made.ok) throw new Error(String(made.error));
681 const issue = openIssue(app, alice, made.value, { title: "Fix <b>this</b>", body: "x" });
682 if (!issue.ok) throw new Error(String(issue.error));
683 const page = await (await get(`/talk?about=${encodeURIComponent(`alice/about-pub#${issue.value.number}`)}`, bob)).text();
684 expect(subject(page)).toContain(`About alice/about-pub#${issue.value.number}: Fix &lt;b&gt;this&lt;/b&gt;`);
685 expect(page).not.toContain("Fix <b>this</b>");
686 expect(subject(await (await get("/talk?about=alice/about-pub", bob)).text())).toContain("About alice/about-pub<");
687 });
688
689 test("a private repository names nothing for another person, and looks like one that does not exist", async () => {
690 const made = await createRepo(app, alice, { name: "about-priv", visibility: "private" });
691 if (!made.ok) throw new Error(String(made.error));
692 const issue = openIssue(app, alice, made.value, { title: "Secret title", body: "x" });
693 if (!issue.ok) throw new Error(String(issue.error));
694 const q = (v: string) => get(`/talk?about=${encodeURIComponent(v)}`, bob).then((r) => r.text());
695 const hidden = await q(`alice/about-priv#${issue.value.number}`);
696 expect(hidden).not.toContain("Secret title");
697 expect(subject(hidden)).toBeUndefined();
698 expect(hidden).toBe(await q("alice/no-such-repo#1"));
699 expect(await q("alice/about-priv")).toBe(await q("alice/no-such-repo"));
700 // Signed out the page sends to sign-in, which only echoes the asked URL: no repository is looked up.
701 const out = await get("/talk?about=alice/about-priv");
702 expect(out.status).toBe(303);
703 expect(out.headers.get("location")).toBe("/signin?next=%2Ftalk%3Fabout%3Dalice%2Fabout-priv");
704 expect(await out.text()).not.toContain("Secret title");
705 expect(await (await get(`/talk?about=${encodeURIComponent(`alice/about-priv#${issue.value.number}`)}`, alice)).text()).toContain("Secret title");
706 // The core lookup both doors share: the owner reads it, another person and nobody do not.
707 const ref = `alice/about-priv#${issue.value.number}`;
708 expect(aboutLine(app.db, ref, alice)).toBe(`About ${ref}: Secret title`);
709 expect(aboutLine(app.db, ref, bob)).toBeNull();
710 expect(aboutLine(app.db, ref, undefined)).toBeNull();
711 expect(aboutLine(app.db, "alice/about-priv", undefined)).toBeNull();
712 expect(aboutLine(app.db, "../x", alice)).toBeNull();
713 });
714
715 test("a malformed value is ignored: the page equals plain /talk", async () => {
716 const plain = await (await get("/talk", bob)).text();
717 for (const bad of ["../x", "a/b#x", "x".repeat(300)]) {
718 const res = await get(`/talk?about=${encodeURIComponent(bad)}`, bob);
719 expect(res.status).toBe(200);
720 expect(await res.text()).toBe(plain);
721 }
722 });
723
724 test("a pull of a private repository names nothing for the person who cannot read it", async () => {
725 const made = await createRepo(app, alice, { name: "about-priv-pull", visibility: "private" });
726 if (!made.ok) throw new Error(String(made.error));
727 const issue = openIssue(app, alice, made.value, { title: "Secret pull", body: "x" });
728 if (!issue.ok) throw new Error(String(issue.error));
729 app.db.run("UPDATE issue SET kind = 'pull' WHERE id = ?", [issue.value.id]);
730 const ref = `alice/about-priv-pull#${issue.value.number}`;
731 const seen = await (await get(`/talk?about=${encodeURIComponent(ref)}`, bob)).text();
732 expect(seen).not.toContain("Secret pull");
733 expect(subject(seen)).toBeUndefined();
734 expect(seen).toBe(await (await get(`/talk?about=${encodeURIComponent("alice/none#1")}`, bob)).text());
735 expect(subject(await (await get(`/talk?about=${encodeURIComponent(ref)}`, alice)).text())).toContain("Secret pull");
736 });
737
738 test("the terminal prints the same gated line: `talk --about <ref>`", () => {
739 const run = (user: User | undefined, words: string) => {
740 let out = "";
741 let code: number | undefined;
742 const session = { write: (d: string) => void (out += d), writeStderr: () => {}, exit: (c: number) => void (code = c), onData() {}, onEof() {}, onClose() {} };
743 serveTalk(app, session as never, user, words);
744 return { out, code };
745 };
746 const priv = run(alice, "--about alice/about-priv#1");
747 expect(priv.code).toBe(0);
748 expect(priv.out.startsWith("About alice/about-priv#1: ")).toBe(true);
749 expect(run(bob, "--about alice/about-priv#1")).toEqual({ out: "", code: 0 });
750 expect(run(undefined, "--about alice/about-priv")).toEqual({ out: "", code: 0 });
751 expect(run(bob, "--about alice/about-pub").out).toBe("About alice/about-pub\r\n");
752 expect(run(bob, "--about ../x")).toEqual({ out: "", code: 0 });
753 });
754 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs