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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10494-talk-about-names-its-subject-one-line-ab → main 6 commits fork 1 thread · 1 unresolved
6 files
changed +163
−0
docs/design/areas/talk.md +1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | - `.talk-meta` — who said it and when, with the copy button. |
| 18 | 18 | - `.talk-copy` — a quiet text button that copies a message or a code block. |
| 19 | 19 | - `.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. | |
| 20 | 21 | - `.talk-jump` — "Latest" button, shown when you have scrolled up from the end. |
| 21 | 22 | - `.talk-chats` — the conversations list above the chat (#8814): New, the person's conversations newest first, and the Archived filter link. |
| 22 | 23 | - `.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, | ||
| 18 | 18 | |
| 19 | 19 | 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. |
| 20 | 20 | |
| 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 | ||
| 21 | 25 | ## Many conversations (#8814) |
| 22 | 26 | |
| 23 | 27 | 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 @@ | ||
| 15 | 15 | * `talk-again edit <words>`, `talk-again regenerate` and `talk-again retry` |
| 16 | 16 | * are the same three actions the page has (#8817), through `talkAgain`. |
| 17 | 17 | * |
| 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 | * | |
| 18 | 23 | * `talk --attach <name> <words>` sends one file with the words: its bytes are |
| 19 | 24 | * the exec's standard input (`cat shot.png | ssh <forge> talk --attach |
| 20 | 25 | * 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 ". | ||
| 26 | 31 | import { FILE_WORDS, IMAGE_MAX, pendingAmong, removePending, storeFile } from "../core/conversation_files.ts"; |
| 27 | 32 | import { contextOf, contextText, dollars, turnCosts } from "../core/conversation_cost.ts"; |
| 28 | 33 | import { openConversation } from "../core/conversations.ts"; |
| 34 | import { aboutLine } from "../core/talk_about.ts"; | |
| 29 | 35 | import type { App } from "../app.ts"; |
| 30 | 36 | import type { RedoHow } from "../crew/conversation.ts"; |
| 31 | 37 | import type { User } from "../core/users.ts"; |
| @@ -58,6 +64,12 @@ export function parseAttach(words: string): { name: string; words: string } | un | ||
| 58 | 64 | return m === null ? undefined : { name: m[1]!, words: m[2] ?? "" }; |
| 59 | 65 | } |
| 60 | 66 | |
| 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 | ||
| 61 | 73 | /** Standard input to its end, or `too_large` past `max` bytes. */ |
| 62 | 74 | function readStdin(session: SshSession, max: number): Promise<Uint8Array | "too_large"> { |
| 63 | 75 | return new Promise((resolve) => { |
| @@ -98,6 +110,13 @@ export function serveTalk(app: App, session: SshSession, user: User | undefined, | ||
| 98 | 110 | session.write(`cancelled${CRLF}`); |
| 99 | 111 | return session.exit(0); |
| 100 | 112 | } |
| 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 | } | |
| 101 | 120 | const during = parseDuring(words); |
| 102 | 121 | if (during !== undefined) words = during.words; |
| 103 | 122 | if (isStop(words)) { |
src/web/pages/talk.ts +8
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | type Turn, |
| 39 | 39 | } from "../../core/conversations.ts"; |
| 40 | 40 | import { contextOf, contextText, dollars, turnCosts, type Context } from "../../core/conversation_cost.ts"; |
| 41 | import { aboutLine as aboutText } from "../../core/talk_about.ts"; | |
| 41 | 42 | import { WORDS_MAX } from "../../core/fence.ts"; |
| 42 | 43 | import { thinkingOf, worksOf, type Work } from "../../core/talk_work.ts"; |
| 43 | 44 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -70,6 +71,12 @@ const HERE = "/talk"; | ||
| 70 | 71 | const FIND_CHARS = 100; |
| 71 | 72 | const CONVERSATION_ID = /^[0-9a-z]{1,40}$/; |
| 72 | 73 | |
| 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 | ||
| 73 | 80 | /** What a conversation is called in a list: its title, else the day it began. */ |
| 74 | 81 | const nameOf = (c: Conversation): string => (c.title === "" ? `Chat of ${c.createdAt.slice(0, 10)}` : c.title); |
| 75 | 82 | /** Where a conversation lives. */ |
| @@ -316,6 +323,7 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi | ||
| 316 | 323 | </ol> |
| 317 | 324 | <div id="end"></div> |
| 318 | 325 | </div> |
| 326 | ${aboutLine(ctx)} | |
| 319 | 327 | <form class="talk-compose" id="compose" method="post" action="${editing === undefined ? here : `${here}/again/edit`}" ${editing === undefined ? null : html`data-edit`}> |
| 320 | 328 | ${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>`} |
| 321 | 329 | <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"; | ||
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| 14 | 14 | import { sayNo } from "../src/actions/conversation_confirms.ts"; |
| 15 | import { openIssue } from "../src/actions/issues.ts"; | |
| 15 | 16 | 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"; | |
| 16 | 19 | import { appendTurn, listTurns, openConversation } from "../src/core/conversations.ts"; |
| 17 | 20 | import { hold, waitingFor } from "../src/core/fence.ts"; |
| 18 | 21 | import { getRepoById } from "../src/core/repos.ts"; |
| @@ -662,3 +665,90 @@ describe("cost and context (#8822)", () => { | ||
| 662 | 665 | expect(calm).not.toContain("A new conversation answers faster"); |
| 663 | 666 | }); |
| 664 | 667 | }); |
| 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 <b>this</b>`); | |
| 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 | }); |