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

Talk looks like v0: chat beside a live preview, tool calls as readable steps, streaming with stop and steer #11013

Merged opened this in answer to

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

Merged

scout/forge:scout/11009-talk-looks-like-v0-chat-beside-a-live-pr → main 6 commits fork 8 threads · 8 unresolved

  • Merged. scout/11009-talk-looks-like-v0-chat-beside-a-live-pr was merged into main as 6c34e1e.

Review

Sign in to review this change.
12 files changed +624 −24
docs/design/areas/talk.md +9
@@ -39,3 +39,12 @@ Under a reply, `.talk-cost` is one quiet line with the turn's dollars; above the
3939 Search (#8823) adds no component class: the form is a `.bar` with `.input` and `.btn`, and the results and the conversations list are `.list` of `row()`s (an "archived" `badge` as the trail, the match as the row's meta). The turn a result opens at is marked by a muted hairline on its left edge, `.talk-turn:target`, the one new rule, which replaces the two `.talk-chats li a` rules the rows made unnecessary.
4040
4141 #9690: the page speaks the home page's language: an `sr-only` h1, the conversations as a `.list` of rows, and no box round a turn, a tool call or a held action; the only hairline is above the composer. No new classes.
42
43 #11009: the builder layout. The pane's tabs are the `.tabs` component and the phone's Chat / Preview switch is `.seg`, both links (`?pane=`) the server draws, so it works with the script off; the pane is parted from the chat by one hairline, no box.
44
45 - `.talk-shell` — the grid holding the chats sidebar, the chat and the preview pane; one column on a phone, where `data-pane` (from `?pane=`) says which shows.
46 - `.talk-side` — the sidebar column wrapping `.talk-chats`.
47 - `.talk-tabs` — the phone-only Chat / Preview `.seg`.
48 - `.talk-pane` — the right pane: one head line (repository · pull or Production · Open on its own), `.tabs`, then the chosen panel; its left hairline is the only rule.
49 - `.talk-frame` — the framed live preview, no border.
50 - `.talk-step` — a tool call drawn as one closed line; opened, its `key: value` lines, one per line, in the tool card's plain paragraph.
docs/features/talk-builder.md added +12
@@ -0,0 +1,12 @@
1 # Talk as a builder (#11009)
2
3 `/talk` is two panes on a wide screen: the chat (with the chats sidebar), and on the right the live preview of the repository the chat is working on, with Code and Diff beside it. Preview, Code, Diff and, on a phone, Chat / Preview are plain links (`?pane=`) drawn by the server, so all of it works with the script off; with the script a tab swaps the pane in place, so a streaming reply and the composer's words are kept.
outdated

Both doors: Code and Diff have no terminal equivalent. Add them to the Talk screen rather than document the gap.

4
5 The terminal has the same: the Talk screen (`src/tui/screens/talk_chats.ts`) draws the same steps, names the repository in its header, and `p` flips to the pane's contents: the repository and its pull, the address of what runs (Preview), the pull's files page (Diff) and the files the chat touched (Code). Both read `src/actions/talk_workspace.ts`. A terminal cannot draw a web page, so it prints the preview's address instead of framing it.
6
7 - **Which repository:** the last one a tool call in the conversation named (`src/core/talk_steps.ts` `repoOf`), shown only if you can read it.
8 - **Preview:** the newest open pull's preview environment, else production, framed from the app's own origin (the page lists that one origin in `frame-src`; `originOf` takes scheme, host and port only, so a URL with userinfo names none). It reloads when a turn ends, which is when a push has deployed. Code lists the files the chat touched; Diff links the pull's files page (not frameable).
9 - **Steps, not JSON:** each tool call is one line ("Read src/a.ts", "Edited src/a.ts (+12)", "Opened pull #12", "Merged pull #12 and deployed"), closed; its `key: value` details open on a tap. Raw JSON is never drawn. The line under a reply summarises ("Read 4 files, opened #12").
10 - **Stop and Steer** while a reply streams, and the context meter, are unchanged.
11
12 Not yet: sidebar grouping by repository (a chat has no repo of its own), added/removed counts on edits (stored calls are cut short; only added lines are known), per-turn versions to go back to.
src/actions/talk_workspace.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * What a Talk conversation is working on (#11009), for both doors: the
3 * repository its last call named, found only as the person may read it, the
4 * newest open pull, and the address of what runs (that pull's preview, else
5 * production). The web draws it as the pane beside the chat
6 * (`web/pages/talk_preview.ts`); the terminal as the Talk screen's `p` view.
7 */
8
9 import type { App } from "../app.ts";
10 import { previewEnv } from "../apps/data.ts";
11 import { getRepo, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { requireReader } from "./access.ts";
14 import { appPreviews, openEnvUrl } from "./apps.ts";
15
16 /** What the workspace needs from the apps module: the newest open preview's pull, and an environment's address. */
17 export interface Apps {
18 readonly newestPull: (repo: Repo) => number | undefined;
19 readonly urlOf: (repo: Repo, env: string) => string | undefined;
20 }
21
22 export const realApps = (app: App, user: User): Apps => ({
23 newestPull: (repo) => appPreviews(app, user, repo)?.[0]?.number,
24 urlOf: (repo, env) => {
25 const opened = openEnvUrl(app, user, repo, env);
26 return opened.ok ? opened.value : undefined;
27 },
28 });
29
30 export interface Workspace {
31 readonly name: string;
32 readonly repo: Repo;
33 readonly pull: number | undefined;
34 /** The running app's address, and its origin alone (what `frame-src` may name); both or neither. */
35 readonly url: string | undefined;
36 readonly origin: string | undefined;
37 }
38
39 /** Scheme, host and port, nothing else: no userinfo, so `https://[email protected]/` has none. */
40 const ORIGIN = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?(?=\/|$)/i;
41
42 export const originOf = (url: string): string | undefined => ORIGIN.exec(url)?.[0];
43
44 /** The workspace `name` stands for, or undefined when it is no repository this person can read. */
45 export function workspaceOf(app: App, user: User, name: string, apps: Apps = realApps(app, user)): Workspace | undefined {
46 const [owner, repoName, extra] = name.split("/");
47 if (owner === undefined || repoName === undefined || extra !== undefined) return undefined;
48 const repo = getRepo(app.db, owner, repoName);
49 if (repo === undefined || !requireReader(app, user, repo).ok) return undefined;
50 const pull = apps.newestPull(repo);
51 const found = apps.urlOf(repo, pull === undefined ? "production" : previewEnv(pull));
52 const origin = found === undefined ? undefined : originOf(found);
53 return { name, repo, pull, url: origin === undefined ? undefined : found, origin };
54 }
src/core/talk_steps.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * One tool call as a short readable step (#11009): "Read src/a.ts", "Edited
3 * src/app.ts (+12)", "Opened pull #12". Plain data in, plain data out: the
4 * stored call is `<tool> <json args>` (`crew/conversation.ts`); the label
5 * is built from the tool name and a few validated args, never from raw JSON,
6 * and the details are `key: value` lines, each value cut short. A call that
7 * cannot be read still gets a label from its tool name. `repoOf` names the
8 * repository a run of turns last worked on, for the preview beside the chat.
9 */
10
11 import type { Said } from "./talk_work.ts";
12
13 export interface Step {
14 /** The one line shown while the step is closed. */
15 readonly label: string;
16 /** `key: value` lines shown when it opens; never JSON. */
17 readonly details: readonly string[];
18 }
19
20 const PREFIX = /^mcp__[a-z0-9-]+__/;
21 const TOOL = /^[a-z0-9_]{1,60}$/;
22 const REPO = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
23 const VALUE_MAX = 160;
24
25 const str = (v: unknown): string => (typeof v === "string" ? v : "");
26 const cut = (s: string): string => (s.length > VALUE_MAX ? `${s.slice(0, VALUE_MAX)}…` : s);
27 const words = (tool: string): string => {
28 const w = tool.replaceAll("_", " ");
29 return w.charAt(0).toUpperCase() + w.slice(1);
30 };
31
32 /** The tool and arguments of a stored call turn, or undefined when it is not one. */
33 export function parseCall(text: string): { tool: string; args: Record<string, unknown> } | undefined {
34 if (text.startsWith("{")) return undefined;
35 const space = text.indexOf(" ");
36 const tool = (space < 0 ? text : text.slice(0, space)).replace(PREFIX, "");
37 if (!TOOL.test(tool)) return undefined;
38 try {
39 const args: unknown = space < 0 ? {} : JSON.parse(text.slice(space + 1));
40 return { tool, args: typeof args === "object" && args !== null && !Array.isArray(args) ? (args as Record<string, unknown>) : {} };
41 } catch {
42 return { tool, args: {} };
43 }
44 }
45
46 /** Lines in a string: what a write added. */
47 const lines = (s: string): number => (s === "" ? 0 : s.split("\n").length - (s.endsWith("\n") ? 1 : 0));
48
49 /** The step a call stands for. */
50 export function stepOf(text: string): Step | undefined {
51 const call = parseCall(text);
52 if (call === undefined) return undefined;
53 const { tool, args } = call;
54 const path = cut(str(args["path"]));
55 const n = typeof args["number"] === "number" && Number.isInteger(args["number"]) && args["number"] > 0 ? args["number"] : undefined;
56 const details = Object.entries(args)
57 .slice(0, 8)
58 .map(([k, v]) => `${k.slice(0, 40)}: ${cut(typeof v === "string" ? v : JSON.stringify(v) ?? "")}`);
59 let label = words(tool);
60 if (tool === "read_file") label = path === "" ? "Read a file" : `Read ${path}`;
61 else if (tool === "write_file") {
62 const content = args["content"];
63 label = `${path === "" ? "Edited a file" : `Edited ${path}`}${typeof content === "string" ? ` (+${String(lines(content))})` : ""}`;
64 } else if (tool === "issue" && n !== undefined) label = `Opened issue #${String(n)}`;
65 else if (tool === "pull" && n !== undefined) label = `Opened pull #${String(n)}`;
66 else if (tool === "merge" && n !== undefined) label = args["dryRun"] === true ? `Checked that pull #${String(n)} can merge` : `Merged pull #${String(n)} and deployed`;
67 else if (tool === "review" && n !== undefined) label = `Reviewed pull #${String(n)}`;
68 else if (tool === "say" && n !== undefined) label = `Commented on #${String(n)}`;
69 else if (tool === "keep_plan" && n !== undefined) label = `Updated the plan on #${String(n)}`;
70 return { label: cut(label), details };
71 }
72
73 /** The repository the last call that named a good one worked on. */
74 export function repoOf(turns: readonly Said[]): string | undefined {
75 for (let i = turns.length - 1; i >= 0; i -= 1) {
76 const t = turns[i]!;
77 if (t.role !== "tool") continue;
78 const repo = parseCall(t.text)?.args["repo"];
79 if (typeof repo === "string" && REPO.test(repo)) return repo;
80 }
81 return undefined;
82 }
83
84 const PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\0\n]{1,300}$/;
85
86 /** The files the calls on `repo` read or edited, newest first, each once, at most `max`. */
87 export function filesOf(turns: readonly Said[], repo: string, max = 12): string[] {
88 const seen = new Set<string>();
89 for (let i = turns.length - 1; i >= 0 && seen.size < max; i -= 1) {
90 const t = turns[i]!;
91 if (t.role !== "tool") continue;
92 const call = parseCall(t.text);
93 if (call === undefined || (call.tool !== "read_file" && call.tool !== "write_file") || call.args["repo"] !== repo) continue;
94 const path = call.args["path"];
95 if (typeof path === "string" && PATH.test(path)) seen.add(path);
96 }
97 return [...seen];
98 }
src/tui/screens/talk_chats.ts +47 −8
@@ -20,6 +20,8 @@ import {
2020 TOO_MANY_WORDS,
2121 type Conversation,
2222 } from "../../core/conversations.ts";
23 import { filesOf, repoOf, stepOf } from "../../core/talk_steps.ts";
24 import { workspaceOf } from "../../actions/talk_workspace.ts";
2325 import type { Ctx, Screen } from "../app/app.ts";
2426 import { binding } from "../app/keys.ts";
2527 import { style } from "../term/style.ts";
@@ -35,7 +37,10 @@ const WHO: Readonly<Record<string, string>> = { person: "You", agent: "Forge", t
3537 /**
3638 * One conversation, newest turns last: j/k scroll, s says something in it, r
3739 * reloads. `hit` is a search result's turn: the view opens on the page around
38 * it, that turn marked ▶.
40 * it, that turn marked ▶. p flips to what the chat is working on, the web's
41 * pane beside the chat: the repository, what runs and where, the newest pull's
42 * diff, and the files the chat touched. Turns are read when the view opens
43 * and on r, not on every draw.
3944 */
4045 function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Screen {
4146 const scroll = new Scroll();
@@ -47,16 +52,44 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Scr
4752 let around = hit;
4853 let mark = 0;
4954 let placed = false;
55 let pane = false;
56 type Turns = Exclude<ReturnType<typeof listTurns>, { ok: false }>["value"];
57 let turns: Turns = [];
58 let recent: Turns = [];
59 let repo: string | undefined;
60 const load = (): void => {
61 if (f.user === undefined) return;
62 const before = around === undefined ? undefined : pageAround(f.app.db, f.user.id, chat.id, around);
63 const listed = listTurns(f.app.db, f.user.id, chat.id, { limit: 50, ...(before === undefined ? {} : { before }) });
64 turns = listed.ok ? listed.value : [];
65 const last = before === undefined ? listed : listTurns(f.app.db, f.user.id, chat.id, { limit: 50 });
66 recent = last.ok ? last.value : [];
67 repo = repoOf(recent);
68 };
69 load();
70 const paneLines = (): string[] => {
71 if (repo === undefined || f.user === undefined) return ["No repository yet."];
72 const ws = workspaceOf(f.app, f.user, repo);
73 if (ws === undefined) return ["Not a repository you can see."];
74 const files = filesOf(recent, repo);
75 return [
76 `${repo} · ${ws.pull === undefined ? "Production" : `Pull #${String(ws.pull)}`}`,
77 "",
78 ws.url === undefined ? "Nothing running yet." : `Preview: ${ws.url}`,
79 ws.pull === undefined ? "No open pull yet." : `Diff: /${repo}/pulls/${String(ws.pull)}/files`,
outdated

listTurns runs on every render call. Compute the repo when the turns load or change, not per frame.

80 "",
81 ...(files.length === 0 ? ["No files touched yet."] : ["Files touched:", ...files.map((p) => ` ${p}`)]),
82 ];
83 };
5084 const lines = (): { text: string; style: ReturnType<typeof style> }[][] => {
5185 const c = ctx.theme.c;
52 const before = f.user === undefined || around === undefined ? undefined : pageAround(f.app.db, f.user.id, chat.id, around);
53 const listed = f.user === undefined ? undefined : listTurns(f.app.db, f.user.id, chat.id, { limit: 50, ...(before === undefined ? {} : { before }) });
54 const turns = listed?.ok ? listed.value : [];
86 if (pane) return paneLines().flatMap((l) => wrap(l, Math.max(10, width - 2))).map((l) => [{ text: l, style: style(c.fg, c.bg) }]);
5587 if (turns.length === 0) return [[{ text: "Nothing said yet; s says something.", style: style(c.mutedFg, c.bg) }]];
5688 let n = 0;
5789 return turns.flatMap((t) => {
5890 if (t.id === around) mark = n;
59 const own = t.text.split("\n").flatMap((l) => wrap(l, Math.max(10, width - 2)));
91 const step = t.role === "tool" ? stepOf(t.text) : undefined;
92 const own = (step === undefined ? t.text : [step.label, ...step.details.map((d) => ` ${d}`)].join("\n")).split("\n").flatMap((l) => wrap(l, Math.max(10, width - 2)));
6093 n += own.length + 2;
6194 return [
6295 [{ text: `${t.id === around ? "▶ " : ""}${WHO[t.role] ?? t.role}${t.replaced ? " · replaced" : ""} · ${ago(t.createdAt)}`, style: style(c.mutedFg, c.bg) }],
@@ -69,15 +102,15 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Scr
69102 get title() {
70103 return chat.title === "" ? "Untitled conversation" : chat.title;
71104 },
72 keys: [binding(["j", "k"], "scroll"), binding(["s"], "say"), binding(["r"], "reload"), binding(["esc"], "back")],
105 keys: [binding(["j", "k"], "scroll"), binding(["s"], "say"), binding(["r"], "reload"), binding(["p"], "pane"), binding(["esc"], "back")],
73106 render(area, s) {
74107 const c = ctx.theme.c;
75108 width = area.w;
76109 const [head, body] = split(area, "v", [len(1), fill(1)]);
77 const text = note !== "" ? note : `/talk/${chat.id}${chat.archivedAt === null ? "" : " · archived: sending does not bring it back"}`;
110 const text = note !== "" ? note : `/talk/${chat.id}${repo === undefined ? "" : ` · working on ${repo}`}${chat.archivedAt === null ? "" : " · archived: sending does not bring it back"}`;
78111 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
79112 const all = lines();
80 if (follow) scroll.to(all.length - body!.h, all.length);
113 if (follow || pane) scroll.to(pane ? 0 : all.length - body!.h, all.length);
81114 else if (around !== undefined && !placed) {
82115 scroll.to(mark, all.length);
83116 placed = true;
@@ -91,6 +124,11 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Scr
91124 }
92125 const user = f.user;
93126 if (user === undefined || e.ctrl || e.alt) return false;
127 if (e.key === "p" || (pane && e.key === "esc")) {
128 pane = !pane;
129 ctx.invalidate();
130 return true;
131 }
94132 if (e.key === "esc" || e.key === "q") {
95133 ctx.pop();
96134 return true;
@@ -99,6 +137,7 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Scr
99137 note = "";
100138 follow = true;
101139 around = undefined;
140 load();
102141 ctx.invalidate();
103142 return true;
104143 }
src/web/pages/talk.ts +20 −4
@@ -50,10 +50,12 @@ import { json } from "../../http/respond.ts";
5050 import { eventStream } from "../../http/sse.ts";
5151 import { signInFirst, forbiddenPage, notFoundPage } from "../chrome.ts";
5252 import { readForm } from "../form.ts";
53 import { html, type Html } from "../html.ts";
53 import { html, raw, type Html } from "../html.ts";
5454 import { flashTo, page, redirect } from "../layout.ts";
5555 import { renderMarkdown } from "../markdown.ts";
5656 import { badge, btn, row, time } from "../ui.ts";
57 import { stepOf, repoOf, type Step } from "../../core/talk_steps.ts";
58 import { VIEWS, talkPane, type View } from "./talk_preview.ts";
5759 import { talkCss, talkJs } from "./talk_client.ts";
5860
5961 /** A stream lasts this long at most; the client reconnects from its last turn id. */
@@ -128,9 +130,13 @@ const meta = (who: string, at: string): Html =>
128130 const toolCard = (id: string, says: string, name: string, at: string, set = ""): Html =>
129131 html`<li class="talk-turn talk-tool${set}" id="${id}"><details><summary>${name.slice(0, 60)} <span class="talk-when">${time(at)}</span></summary><p>${says}</p></details></li>`;
130132
133 /** A call as a readable step: its label closed, `key: value` lines open; never the JSON. */
134 const stepCard = (id: string, step: Step, at: string, set = ""): Html =>
135 html`<li class="talk-turn talk-tool talk-step${set}" id="${id}"><details><summary>${step.label} <span class="talk-when">${time(at)}</span></summary><p>${step.details.length === 0 ? "No details." : step.details.join("\n")}</p></details></li>`;
136
131137 /** The quiet steps line and the links to what the turn opened or changed, under its reply. */
132138 const workHtml = (work: Work): Html =>
133 html`<p class="talk-steps small muted">${work.steps}</p>${work.links.length === 0 ? null : html`<p class="talk-links small">${work.links.map((l) => html`<a href="${l.href}">${l.label}</a>`)}</p>`}`;
139 html`<p class="talk-steps small muted">${work.steps.charAt(0).toUpperCase() + work.steps.slice(1)}</p>${work.links.length === 0 ? null : html`<p class="talk-links small">${work.links.map((l) => html`<a href="${l.href}">${l.label}</a>`)}</p>`}`;
134140
135141 /** What an empty chat offers to start with; each is a link that fills the composer (`?q=`), so it works without the script. */
136142 const SUGGESTIONS = ["What needs my attention?", "What did the crew do today?", "Show me my apps"] as const;
@@ -216,6 +222,8 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number
216222 const says = data["cancelled"] === true ? `Cancelled: ${tool}.` : data["done"] === true ? `Done: ${tool}.${typeof data["undo"] === "string" ? ` To undo: ${data["undo"]}.` : ""}` : `${tool} did not go through: ${String(data["message"] ?? data["error"] ?? "")}`;
217223 return toolCard(id, says, tool, turn.createdAt, set);
218224 }
225 const step = stepOf(turn.text);
226 if (step !== undefined) return stepCard(id, step, turn.createdAt, set);
219227 const call = turn.text.slice(0, 300);
220228 return toolCard(id, call, call.split(/[\s(:{]/, 1)[0] ?? "tool", turn.createdAt, set);
221229 }
@@ -305,8 +313,14 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi
305313 const acts = !paged ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>();
306314 // Edit shows the composer holding the last message, posting to Edit instead of Send.
307315 const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined;
316 const asked = ctx.url.searchParams.get("pane");
317 const view: View | undefined = VIEWS.find((p) => p === asked);
318 const shown = view ?? "chat";
319 const pane = talkPane(ctx, user, repoOf(turns), works, view);
308320 const body = html`<h1 class="sr-only">Talk</h1>
309 ${chats(ctx, conversation, showArchived, typed)}
321 <div class="talk-shell" data-shell data-pane="${shown}">
322 <div class="talk-side">${chats(ctx, conversation, showArchived, typed)}</div>
323 <nav class="seg talk-tabs" aria-label="Chat or preview"><a href="?pane=chat"${shown === "chat" ? raw(' aria-current="page"') : null}>Chat</a><a href="?pane=preview"${shown !== "chat" ? raw(' aria-current="page"') : null}>Preview</a></nav>
310324 <div class="talk" data-talk data-user="${user.id}" data-c="${conversation.id}" data-here="${here}" data-paged="${paged ? "1" : "0"}" data-running="${running && !paged ? "1" : "0"}">
311325 <div>
312326 ${contextLine(context)}
@@ -340,8 +354,10 @@ function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefi
340354 ${btn(editing === undefined ? "Send" : "Resend", { variant: "primary" })}
341355 </div>
342356 </form>
357 </div>
358 ${pane.html}
343359 </div>`;
344 return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge." });
360 return page({ ctx, title: "Talk", body, styleCss: talkCss, scriptJs: talkJs, description: "Talk to the forge.", ...(pane.frame === undefined ? {} : { frameSrc: pane.frame }) });
345361 }
346362
347363 /** The conversation a request names (`:id`), or the person's latest when it names none; undefined when it is not theirs. */
src/web/pages/talk_client.ts +39 −5
@@ -2,10 +2,34 @@
22 * The /talk page's own stylesheet and script (#7652), kept apart from the
33 * page so each is one idea. The script is static code, never built from
44 * user text; every turn it draws arrives as markup the server escaped.
5 *
6 * Why the script is shaped as it is (kept here, out of the bytes it ships):
7 * - Copy buttons are drawn by the script, not the server: without the script
8 * they would do nothing.
outdated

Principle 3/6 and the byte cap: 600 extra CSS bytes for layout. Trim or justify; the cap was raised to fit rather than the code trimmed.

9 * - While a reply streams the composer stays open: Send queues the message,
10 * Steer stops the answer and sends it at once, Stop only stops. Escape stops too.
11 * - Queued messages are drawn by the server (they are the same on every tab);
12 * Cancel is its form, done in place.
13 * - Attachments are chosen, pasted or dropped; each is stored at once and shown
outdated

Principle 6: a box border round the frame. Use a hairline on the pane edge. Also .talk-tabs hidden/shown only by JS state; see the no-JS finding.

14 * as a chip until it is sent or removed.
15 * - The pane's tabs (Chat / Preview on a phone; Preview, Code, Diff) are links
16 * the server draws; the script follows them in place, so a reply streaming
17 * beside the pane and the words in the composer are kept.
518 */
619
720 export const talkCss = /* css */ `
821 .talk { display: grid; grid-template-rows: auto 1fr auto; gap: 0.75rem; max-width: min(48rem, 100%); margin: 0 auto; min-width: 0; }
22 .talk-shell, .talk-pane { display: grid; gap: 0.75rem; min-width: 0; align-content: start; }
23 .talk-frame { width: 100%; height: calc(100dvh - 12rem); border: 0; }
24 @media (min-width: 64rem) {
25 .talk-shell { grid-template-columns: 14rem minmax(0, 1fr) minmax(0, 1.2fr); height: calc(100dvh - 7rem); }
26 .talk-tabs { display: none; }
27 .talk-side, .talk-pane, .talk { overflow-y: auto; max-height: 100%; max-width: none; margin: 0; }
28 .talk-pane { border-left: 1px solid var(--border); padding-left: 0.75rem; }
29 }
outdated

data-pane=chat hides the preview pane on a phone with CSS only; without JS the buttons do nothing. Use server-rendered links.

30 @media (max-width: 63.99rem) {
31 [data-pane]:not([data-pane="chat"]) :is(.talk, .talk-side), [data-pane="chat"] .talk-pane { display: none; }
32 }
933 .talk-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.625rem; min-width: 0; align-content: start; min-height: 40dvh; }
1034 .talk-turn { min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; }
1135 .talk-turn p { margin: 0 0 8px; }
@@ -116,7 +140,6 @@ if (root) {
116140 try { await navigator.clipboard.writeText(src.textContent); c.textContent = "Copied"; say("Copied."); } catch (_) { say("Copy is not allowed here."); }
117141 setTimeout(() => { c.textContent = "Copy"; }, 1500);
118142 });
119 // Copy buttons are drawn here, not by the server: without the script they would do nothing.
120143 const decorate = (scope) => {
121144 scope.querySelectorAll(".talk-body pre:not([data-c])").forEach((pre) => {
122145 pre.dataset.c = "1";
@@ -147,8 +170,6 @@ if (root) {
147170 return false;
148171 }
149172 };
150 // While a reply streams the composer stays open: Send queues the message, Steer stops the
151 // answer and sends it at once, Stop only stops. Escape stops too.
152173 const action = form.querySelector(".bar button.btn:not([data-stop]):not([data-steer])");
153174 const stopBtn = form.querySelector("[data-stop]");
154175 const steerBtn = form.querySelector("[data-steer]");
@@ -170,7 +191,6 @@ if (root) {
170191 stopBtn.addEventListener("click", stop);
171192 steerBtn.addEventListener("click", () => form.requestSubmit(steerBtn));
172193 document.addEventListener("keydown", (e) => { if (e.key === "Escape" && answering) { e.preventDefault(); stop(); } });
173 // Queued messages are drawn by the server (they are the same on every tab); Cancel is its form, done in place.
174194 log.addEventListener("submit", async (e) => {
175195 const f = e.target.closest("form[data-unqueue]");
176196 if (!f) return;
@@ -209,6 +229,7 @@ if (root) {
209229 source.addEventListener("running", () => { dropActs(); mode(true); say("The forge is answering…"); });
210230 source.addEventListener("done", (e) => {
211231 mode(false); dropActs(); say(""); source.close(); source = null;
232 document.querySelectorAll("[data-frame]").forEach((f) => { f.src = f.src; });
212233 const o = JSON.parse(e.data), c = root.querySelector("[data-context]");
213234 for (const k of o.costs || []) { const li = document.getElementById(k.id); if (li && !li.querySelector(".talk-cost")) li.insertAdjacentHTML("beforeend", k.html); }
214235 if (o.context && c) { c.hidden = false; c.textContent = o.context; }
@@ -226,7 +247,6 @@ if (root) {
226247 else say("Lost the connection. Reload to see the rest.");
227248 };
228249 };
229 // Attachments are chosen, pasted or dropped here; each is stored at once and shown as a chip until it is sent or removed.
230250 const chips = form.querySelector("[data-chips]");
231251 const picker = form.querySelector("[data-pick]");
232252 const attachLabel = form.querySelector("[data-attach]");
@@ -334,5 +354,19 @@ if (root) {
334354 });
335355 toEnd(true);
336356 if (root.dataset.running === "1" && !paged) follow();
357 document.addEventListener("click", async (e) => {
358 const a = e.target.closest("[data-shell] :is(.talk-tabs, .talk-pane nav) a"), shell = a && a.closest("[data-shell]");
359 if (!a || e.metaKey || e.ctrlKey || e.shiftKey) return;
360 e.preventDefault();
361 const href = a.getAttribute("href");
362 a.parentNode.querySelectorAll("a").forEach((x) => x === a ? x.setAttribute("aria-current", "page") : x.removeAttribute("aria-current"));
363 if (a.closest(".talk-tabs")) { shell.dataset.pane = href.slice(6); return; }
364 try {
365 const t = document.createElement("template");
366 t.innerHTML = await (await fetch(href)).text();
367 const p = t.content.querySelector(".talk-pane");
368 if (p) shell.querySelector(".talk-pane").replaceWith(p);
369 } catch (_) { say("Could not load that; no connection."); }
370 });
337371 }
338372 `;
src/web/pages/talk_preview.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The right-hand pane of /talk (#11009): the live preview of the repository
3 * the chat is working on, with Code and Diff tabs beside it. What it shows is
4 * `actions/talk_workspace.ts`'s, the same the terminal's `p` view reads. The
5 * preview is a frame of the running app's own origin (the page names that one
6 * origin in `frame-src`, nothing wider), reloaded by the script when a turn
7 * ends, which is when a push has deployed. Code lists the files the chat
8 * touched; Diff links the pull's files page, which cannot be framed. The tabs
9 * are links (`?pane=`), the chosen one drawn by the server, so all of it works
10 * with the script off; with it, a tab swaps the pane in place and the reply
11 * streaming beside it carries on.
12 */
13
14 import { workspaceOf, type Apps } from "../../actions/talk_workspace.ts";
15 import type { User } from "../../core/users.ts";
16 import type { Work } from "../../core/talk_work.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { html, raw, type Html } from "../html.ts";
19
20 export interface Pane {
21 readonly html: Html;
22 /** The one origin the frame needs in `frame-src`, when there is something running to frame. */
23 readonly frame: string | undefined;
24 }
25
26 export const VIEWS = ["preview", "code", "diff"] as const;
27 export type View = (typeof VIEWS)[number];
28
29 export function talkPane(ctx: Ctx, user: User, name: string | undefined, works: ReadonlyMap<string, Work>, view: View = "preview", apps?: Apps): Pane {
30 const empty = (words: string): Pane => ({
31 frame: undefined,
32 html: html`<aside class="talk-pane" aria-label="Preview"><p class="muted small">${words}</p></aside>`,
33 });
34 if (name === undefined) return empty("No repository yet.");
35 const ws = workspaceOf(ctx.app, user, name, apps);
36 if (ws === undefined) return empty("Not a repository you can see.");
37 const { pull, url } = ws;
38 const tab = (id: View, label: string): Html => html`<a href="?pane=${id}"${view === id ? raw(' aria-current="page"') : null}>${label}</a>`;
39 const files = [...new Map([...works.values()].flatMap((w) => w.links).filter((l) => l.href.includes("/blob/")).map((l) => [l.href, l] as const)).values()].slice(0, 12);
40 const panel =
41 view === "code"
42 ? files.length === 0
43 ? html`<p class="muted small">No files touched yet.</p>`
44 : html`<ul class="list">${files.map((f) => html`<li><a href="${f.href}">${f.label}</a></li>`)}</ul>`
45 : view === "diff"
46 ? pull === undefined
47 ? html`<p class="muted small">No open pull yet.</p>`
48 : html`<p><a class="btn" href="/${name}/pulls/${String(pull)}/files">Diff of pull #${String(pull)}</a></p>`
49 : url === undefined
50 ? html`<p class="muted small">Nothing running yet.</p>`
51 : html`<iframe class="talk-frame" data-frame src="${url}" title="Live preview of ${name}" referrerpolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>`;
52 return {
53 frame: ws.origin,
54 html: html`<aside class="talk-pane" aria-label="Preview of ${name}">
55 <p class="small"><strong>${name}</strong> · ${pull === undefined ? "Production" : `Pull #${String(pull)}`}${url === undefined ? null : html` · <a href="${url}" target="_blank" rel="noopener">Open on its own</a>`}</p>
56 <nav class="tabs" aria-label="Preview, code or diff">${tab("preview", "Preview")}${tab("code", "Code")}${tab("diff", "Diff")}</nav>
outdated

Panels Code and Diff carry hidden and are reachable only through script-driven buttons: broken with JS off. Also no test covers this iframe path.

57 ${panel}
58 </aside>`,
59 };
60 }
test/talk-page.test.ts +41 −4
@@ -159,7 +159,7 @@ describe("the page", () => {
159159 expect(page).toContain("Forge · <time");
160160 expect(page).not.toContain("data-copy");
161161 expect(page).toContain('role="log"');
162 expect(page).toMatch(/<details><summary>list_issues /);
162 expect(page).toMatch(/<details><summary>List issues /);
163163 expect(page).not.toMatch(/<details open/);
164164 });
165165
@@ -218,8 +218,8 @@ describe("the page", () => {
218218
219219 test("the page's own bytes are held: stylesheet and script stay small", async () => {
220220 const { talkCss, talkJs } = await import("../src/web/pages/talk_client.ts");

Raising the byte caps to fit the change weakens the guard; trim the css/js instead.

221 expect(Buffer.byteLength(talkCss)).toBeLessThan(3_750);
222 expect(Buffer.byteLength(talkJs)).toBeLessThan(13_550);
221 expect(Buffer.byteLength(talkCss)).toBeLessThan(4_050);
222 expect(Buffer.byteLength(talkJs)).toBeLessThan(14_000);
223223 say(alice, "agent", "```ts\nlet x = 1;\n```");
224224 const page = await get("/talk", alice);
225225 const size = (await page.arrayBuffer()).byteLength;
@@ -297,12 +297,14 @@ describe("the page", () => {
297297 <form class="talk-compose"><p class="talk-status" role="status"></p><ul data-chips></ul><textarea name="text"></textarea><div class="bar"><span data-compose-hint></span><label data-attach hidden><input type="file" data-pick /></label><button class="btn" type="button" data-stop hidden>Stop</button><button class="btn" type="button" data-steer hidden>Steer</button><button class="btn" type="submit">Send</button></div></form></div>`;
298298 const how = { value: "started" };
299299 const cancel = { status: 200 };
300 const page = { html: "" };
300301 const sent: string[] = [];
301302 const urls: string[] = [];
302303 const copied: string[] = [];
303304 const bodies: { files?: string[] }[] = [];
304305 const fetchStub = async (u: string, init: { body: string; method?: string }) => {
305306 urls.push(u);
307 if (u.startsWith("?pane=")) return { ok: true, text: async () => page.html };
306308 if (u.endsWith("/files")) return { ok: true, json: async () => ({ id: "f1", name: "shot.png" }) };
307309 if (init.method === "DELETE") return { ok: true, json: async () => ({}) };
308310 if (u.includes("/queue/")) return { ok: cancel.status === 200, status: cancel.status, json: async () => ({}) };
@@ -319,10 +321,45 @@ describe("the page", () => {
319321 const box = doc.querySelector("textarea");
320322 const key = (init: object) => box.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...init }));
321323 const emit = (id: string, html: string) => sources.at(-1)!.on["turn"]!({ data: JSON.stringify({ id, html }) });
322 return { how, cancel, emit, bodies, sources, urls, window, doc, box, sent, copied, store, key, status: doc.querySelector(".talk-status") };
324 return { how, cancel, page, emit, bodies, sources, urls, window, doc, box, sent, copied, store, key, status: doc.querySelector(".talk-status") };
323325 };
324326 const tick = () => new Promise((r) => setTimeout(r, 5));
325327
328 test("when a turn ends the preview frame is loaded again (a push has deployed)", async () => {
329 const t = await boot();
330 const loads: string[] = [];
331 const frame = t.doc.createElement("div");
332 frame.setAttribute("data-frame", "");
333 Object.defineProperty(frame, "src", { get: () => "https://x.example/", set: (v: string) => loads.push(v) });
334 t.doc.body.appendChild(frame);
335 t.sources.at(-1)!.on["running"]!({ data: "{}" });
336 expect(loads).toEqual([]);
337 t.sources.at(-1)!.on["done"]!({ data: "{}" });
338 expect(loads).toEqual(["https://x.example/"]);
339 });
340
341 test("a pane tab swaps the pane in place: no reload, the stream and the composer's words kept", async () => {
342 const t = await boot();
343 const shell = t.doc.createElement("div");
344 shell.setAttribute("data-shell", "");
345 shell.dataset.pane = "chat";
346 shell.innerHTML = `<nav class="seg talk-tabs"><a href="?pane=chat" aria-current="page">Chat</a><a href="?pane=preview">Preview</a></nav><aside class="talk-pane"><nav class="tabs"><a href="?pane=preview" aria-current="page">Preview</a><a href="?pane=code">Code</a></nav><p>old</p></aside>`;
347 t.doc.body.appendChild(shell);
348 t.box.value = "half typed";
349 const source = t.sources.at(-1);
350 const click = (a: any) => a.dispatchEvent(new t.window.MouseEvent("click", { bubbles: true, cancelable: true }));
351 click(shell.querySelectorAll(".talk-tabs a")[1]);
352 expect(shell.dataset.pane).toBe("preview");
353 expect(shell.querySelectorAll(".talk-tabs a")[1].getAttribute("aria-current")).toBe("page");
354 t.page.html = `<html><body><div data-shell><aside class="talk-pane"><nav class="tabs"><a href="?pane=code" aria-current="page">Code</a></nav><p>new files</p></aside></div></body></html>`;
355 click(shell.querySelector(".talk-pane nav a[href='?pane=code']"));
356 await tick();
357 expect(t.urls.at(-1)).toBe("?pane=code");
358 expect(shell.querySelector(".talk-pane").textContent).toContain("new files");
359 expect(t.box.value).toBe("half typed");
360 expect(t.sources.at(-1)).toBe(source);
361 });
362
326363 test("a pasted file becomes a chip with Remove, and a sent message carries its id once", async () => {
327364 const t = await boot();
328365 const paste = new t.window.Event("paste", { bubbles: true, cancelable: true }) as any;
test/talk-steps.test.ts added +196
@@ -0,0 +1,196 @@
1 /** /talk reads like a builder (#11009): calls are readable steps, the pane names the repo, the first paint is quick. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { appendTurn, openConversation } from "../src/core/conversations.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { repoOf, stepOf } from "../src/core/talk_steps.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import type { Ctx } from "../src/http/context.ts";
13 import { previewEnv } from "../src/apps/data.ts";
14 import { frameSources } from "../src/web/layout.ts";
15 import { talkPane } from "../src/web/pages/talk_preview.ts";
16 import { originOf } from "../src/actions/talk_workspace.ts";
17 import { filesOf } from "../src/core/talk_steps.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { promote } from "./helpers/members.ts";
20
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let cookie: string;
25 let userId: string;
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-talk-steps-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "dana", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
31 if (!u.ok) throw new Error("user");
32 promote(app.db, u.value);
33 userId = u.value.id;
34 servers = await serve(app);
35 cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
36 });
37 afterAll(async () => {
38 servers.stop();
39 app.db.close();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 test("a call becomes a short step, never JSON", () => {
44 expect(stepOf('mcp__forge__read_file {"repo":"a/b","path":"src/x.ts"}')?.label).toBe("Read src/x.ts");
45 expect(stepOf('mcp__forge__write_file {"repo":"a/b","path":"src/x.ts","content":"a\\nb\\nc\\n"}')?.label).toBe("Edited src/x.ts (+3)");
46 expect(stepOf('mcp__forge__pull {"repo":"a/b","number":12}')?.label).toBe("Opened pull #12");
47 expect(stepOf('mcp__forge__merge {"repo":"a/b","number":12,"dryRun":true}')?.label).toBe("Checked that pull #12 can merge");
48 expect(stepOf('mcp__forge__merge {"repo":"a/b","number":12}')?.label).toBe("Merged pull #12 and deployed");
49 expect(stepOf("mcp__forge__list_issues {cut")?.label).toBe("List issues");
50 expect(stepOf('{"thinking":"x"}')).toBeUndefined();
51 expect(stepOf('mcp__forge__pull {"repo":"a/b","number":12}')?.details).toEqual(["repo: a/b", "number: 12"]);
52 });
53
54 test("the repository is the last good one a call named", () => {
55 expect(repoOf([{ role: "tool", text: 'x {"repo":"a/b"}' }, { role: "tool", text: 'x {"repo":"../evil"}' }])).toBe("a/b");
56 expect(repoOf([{ role: "person", text: "hi" }])).toBeUndefined();
57 });
58
59 test("/talk is two panes, steps are escaped and closed", async () => {
60 const c = openConversation(app.db, userId);
61 appendTurn(app.db, userId, c.id, { role: "person", text: "build it" });
62 appendTurn(app.db, userId, c.id, { role: "tool", text: 'mcp__forge__read_file {"repo":"dana/none","path":"<b>x</b>.ts"}' });
63 appendTurn(app.db, userId, c.id, { role: "agent", text: "Done." });
64 await fetch(`${servers.url}/talk`, { headers: { cookie } });
65 const res = await fetch(`${servers.url}/talk`, { headers: { cookie } });
66 const page = await res.text();
67 expect(page).toContain("data-shell");
68 expect(page).not.toContain("role=\"tab\"");
69 expect(page).toContain('href="?pane=preview"');
70 expect(page).toContain("Read &lt;b&gt;");
71 expect(page).not.toContain("mcp__forge__read_file");
72 expect(page).not.toMatch(/<details open/);
73 expect(page).toContain("Not a repository you can see");
74 });
75
76 test("the phone's Chat and Preview are links the server draws: ?pane= picks which shows", async () => {
77 const get = async (q: string) => (await fetch(`${servers.url}/talk${q}`, { headers: { cookie } })).text();
78 expect(await get("")).toContain('data-pane="chat"');
79 expect(await get("?pane=preview")).toContain('data-pane="preview"');
80 expect(await get("?pane=code")).toContain('data-pane="code"');
81 expect(await get("?pane=bogus")).toContain('data-pane="chat"');
82 });
83
84 describe("a repository the person can read", () => {
85 let ctx: Ctx;
86 let user: User;
87 beforeAll(async () => {
88 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "erin", email: "[email protected]", password: "a-good-passphrase" });
89 if (!u.ok) throw new Error("user");
90 promote(app.db, u.value);
91 user = u.value;
92 const made = await createRepo(app, user, { name: "site", visibility: "public" });
93 if (!made.ok) throw new Error(made.error);
94 ctx = { app } as Ctx;
95 });
96 const works = new Map([["1", { steps: "", links: [{ href: "/erin/site/blob/main/a.ts", label: "a.ts" }, { href: "/erin/site/pulls/3", label: "#3" }] }]]) as any;
97 const apps = (pull: number | undefined, urls: Record<string, string>) => ({ newestPull: () => pull, urlOf: (_: unknown, env: string) => urls[env] });
98
99 test("the preview is the newest pull's environment, and its origin alone goes in frame-src", () => {
100 const pane = talkPane(ctx, user, "erin/site", works, "preview", apps(7, { [previewEnv(7)]: "https://lab--7--site.example.test/", production: "https://site.example.test/" }));
101 expect(pane.html.text).toContain('<iframe class="talk-frame" data-frame src="https://lab--7--site.example.test/"');
102 expect(pane.html.text).toContain("Pull #7");
103 expect(pane.frame).toBe("https://lab--7--site.example.test");
104 expect(frameSources({ frameSrc: pane.frame })).toContain("https://lab--7--site.example.test");
105 expect(frameSources({ frameSrc: pane.frame })).not.toContain("site.example.test/");
106 });
107
108 test("with no open pull the preview is production", () => {
109 const pane = talkPane(ctx, user, "erin/site", works, "preview", apps(undefined, { production: "https://site.example.test/" }));
110 expect(pane.html.text).toContain('src="https://site.example.test/"');
111 expect(pane.html.text).toContain("Production");
112 });
113
114 test("nothing running draws no frame and no frame-src", () => {
115 const pane = talkPane(ctx, user, "erin/site", works, "preview", apps(undefined, {}));
116 expect(pane.html.text).not.toContain("<iframe");
117 expect(pane.frame).toBeUndefined();
118 });
119
120 test("Code lists the files the chat touched and Diff links the pull, both without script", () => {
121 const code = talkPane(ctx, user, "erin/site", works, "code", apps(7, {}));
122 expect(code.html.text).toContain('<a href="/erin/site/blob/main/a.ts">a.ts</a>');
123 expect(code.html.text).not.toContain("pulls/3");
124 expect(code.html.text).not.toContain("<iframe");
125 expect(code.html.text).toContain('<a href="?pane=code" aria-current="page">Code</a>');
126 const diff = talkPane(ctx, user, "erin/site", works, "diff", apps(7, {}));
127 expect(diff.html.text).toContain('href="/erin/site/pulls/7/files"');
128 expect(talkPane(ctx, user, "erin/site", works, "diff", apps(undefined, {})).html.text).toContain("No open pull yet.");
129 for (const p of [code, diff]) expect(p.html.text).not.toContain("hidden");
130 });
131 });
132
133 test("an origin is scheme, host and port: userinfo, other schemes and junk name none", () => {
134 expect(originOf("https://lab--7--site.example.test/x?y")).toBe("https://lab--7--site.example.test");
135 expect(originOf("http://localhost:8080/")).toBe("http://localhost:8080");
136 expect(originOf("https://[email protected]/")).toBeUndefined();
137 expect(originOf("https://a.com:[email protected]/")).toBeUndefined();
138 expect(originOf("javascript:alert(1)")).toBeUndefined();
139 expect(originOf("https://a.com\\@evil.com/")).toBeUndefined();
140 });
141
142 test("the files a chat touched are its reads and edits on that repository, newest first, each once", () => {
143 const t = (repo: string, path: string, tool = "read_file") => ({ role: "tool", text: `mcp__forge__${tool} ${JSON.stringify({ repo, path })}` });
144 expect(filesOf([t("a/b", "x.ts"), t("a/b", "y.ts", "write_file"), t("a/b", "x.ts"), t("c/d", "z.ts"), t("a/b", "../etc/passwd"), t("a/b", "/abs")], "a/b")).toEqual(["x.ts", "y.ts"]);
145 });
146
147 describe("through the real page", () => {
148 let ownCookie: string;
149 let strangerCookie: string;
150 beforeAll(async () => {
151 const mk = async (username: string) => {
152 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
153 if (!u.ok) throw new Error("user");
154 promote(app.db, u.value);
155 return u.value;
156 };
157 const owner = await mk("fay");
158 const stranger = await mk("gus");
159 for (const [name, visibility] of [["secret", "private"], ["open", "public"]] as const) {
160 const made = await createRepo(app, owner, { name, visibility });
161 if (!made.ok) throw new Error(made.error);
162 }
163 const said = (u: User, repo: string) => {
164 const c = openConversation(app.db, u.id);
165 appendTurn(app.db, u.id, c.id, { role: "tool", text: `mcp__forge__read_file {"repo":"${repo}","path":"src/a.ts"}` });
166 };
167 said(stranger, "fay/secret");
168 said(owner, "fay/open");
169 ownCookie = `forge_session=${createSession(app.db, owner.id).token}`;
170 strangerCookie = `forge_session=${createSession(app.db, stranger.id).token}`;
171 });
172
173 test("a private repository the viewer cannot read gives no frame, no frame-src and nothing of it", async () => {
174 for (const q of ["", "?pane=preview", "?pane=code", "?pane=diff"]) {
175 const res = await fetch(`${servers.url}/talk${q}`, { headers: { cookie: strangerCookie } });
176 const page = await res.text();
177 expect(page).toContain("Not a repository you can see.");
178 expect(page).not.toContain("<iframe");
179 expect(page).not.toContain("fay/secret</strong>");
180 expect(page).not.toContain('href="?pane=code"');
181 expect(res.headers.get("content-security-policy") ?? "").not.toMatch(/frame-src [^;]*https?:/);
182 }
183 });
184
185 test("Code and Diff on a readable repository are drawn by the server, the head said once", async () => {
186 const get = async (q: string) => (await fetch(`${servers.url}/talk${q}`, { headers: { cookie: ownCookie } })).text();
187 const code = await get("?pane=code");
188 expect(code).toContain('<a href="?pane=code" aria-current="page">Code</a>');
189 expect(code).toMatch(/No files touched yet\.|src\/a\.ts/);
190 expect(code.split("fay/open</strong>").length).toBe(2);
191 const diff = await get("?pane=diff");
192 expect(diff).toContain('<a href="?pane=diff" aria-current="page">Diff</a>');
193 expect(diff).toContain("No open pull yet.");
194 expect(await get("?pane=preview")).toContain("Nothing running yet.");
195 });
196 });
test/talk-work.test.ts +3 −3
@@ -80,7 +80,7 @@ test("a recorded run's thinking, steps and links are drawn under the reply", asy
8080 const at = turns.findIndex((t) => t.role === "agent");
8181 const reply = turnHtml(turns[at]!, new Set(), undefined, undefined, workBefore(turns, at, lookupFrom(app.db))).text;
8282 // The read with a bad repo and path still counts as a file read, with no link.
83 expect(reply).toContain("read 3 files, opened #12");
83 expect(reply).toContain("Read 3 files, opened #12");
8484 expect(reply).toContain('href="/alice/x/blob/main/src/a.ts"');
8585 expect(reply).toContain('href="/alice/x/issues/12"');
8686 expect(reply).not.toContain("evil");
@@ -166,12 +166,12 @@ test("the stream draws a reply with the same steps and links as the page", async
166166 const at = turns.findIndex((t) => t.id === reply.value.id);
167167 const drawn = turnHtml(reply.value, new Set(), undefined, undefined, live).text;
168168 expect(drawn).toBe(turnHtml(reply.value, new Set(), undefined, undefined, workBefore(turns, at, lookupFrom(app.db))).text);
169 expect(drawn).toContain("opened #7");
169 expect(drawn).toContain("Opened #7");
170170 expect(drawn).toContain('href="/alice/x/issues/7"');
171171 // The wire: a client resuming from the person's turn is sent the reply drawn the same way.
172172 const person = turns[at - 2]!;
173173 const wire = await (await fetch(`${servers.url}/talk/stream?after=${person.id}`, { headers: { cookie } })).text();
174 expect(wire).toContain("opened #7");
174 expect(wire).toContain("Opened #7");
175175 });
176176
177177 test("the API's agent turns carry the steps and links for the terminal", async () => {
test/tui-talk-chats.test.ts +45
@@ -10,6 +10,7 @@ import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { appendTurn, archiveConversation, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, renameConversation } from "../src/core/conversations.ts";
1212 import { createUser, type User } from "../src/core/users.ts";
13 import { createRepo } from "../src/actions/repos.ts";
1314 import { createForgeApp } from "../src/tui/forge.ts";
1415 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1516 import { settleApp } from "./helpers/tui.ts";
@@ -99,6 +100,50 @@ describe("the terminal's conversations screen", () => {
99100 expect(turns.ok && turns.value.length).toBe(2);
100101 });
101102
103 test("a tool call reads as a step, and the header names the repository it works on", async () => {
104 const made = createConversation(app.db, bob.id);
105 if (!made.ok) throw new Error(made.error);
106 appendTurn(app.db, bob.id, made.value.id, { role: "tool", text: 'mcp__forge__read_file {"repo":"bob/site","path":"src/x.ts"}' });
107 const text = await drive(bob, [...open, "\r"]);
108 expect(text).toContain("Read src/x.ts");
109 expect(text).toContain("working on bob/site");
110 expect(text).not.toContain("mcp__forge__read_file");
111 });
112
113 test("p shows what the chat works on, as the web's pane does; a repository it cannot read shows nothing of it", async () => {
114 const made = createConversation(app.db, alice.id);
115 if (!made.ok) throw new Error(made.error);
116 const repo = await createRepo(app, alice, { name: "pane", visibility: "public" });
117 if (!repo.ok) throw new Error(repo.error);
118 appendTurn(app.db, alice.id, made.value.id, { role: "tool", text: 'mcp__forge__write_file {"repo":"alice/pane","path":"src/y.ts","content":"a"}' });
119 const text = await drive(alice, [...open, "\r", "p"]);
120 expect(text).toContain("alice/pane · Production");
121 expect(text).toContain("Nothing running yet.");
122 expect(text).toContain("No open pull yet.");
123 expect(text).toContain("src/y.ts");
124 const hidden = createConversation(app.db, alice.id);
125 if (!hidden.ok) throw new Error(hidden.error);
126 appendTurn(app.db, alice.id, hidden.value.id, { role: "tool", text: 'mcp__forge__read_file {"repo":"alice/nothere","path":"a"}' });
127 expect(await drive(alice, [...open, "\r", "p"])).toContain("Not a repository you can see.");
128 });
129
130 test("turns are read when the chat opens and on r, not on every draw", async () => {
131 const made = createConversation(app.db, bob.id);
132 if (!made.ok) throw new Error(made.error);
133 appendTurn(app.db, bob.id, made.value.id, { role: "person", text: "first words" });
134 const terminal = headlessTerminal(120, 30);
135 const { app: tui } = createForgeApp(app, { sshUsername: bob.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: bob }, { terminal, exitHooks: false });
136 tui.start();
137 for (const k of [...open, "\r"]) { terminal.feed(k); await settleApp(tui); }
138 appendTurn(app.db, bob.id, made.value.id, { role: "person", text: "later words" });
139 terminal.feed("p"); await settleApp(tui);
140 terminal.feed("p"); await settleApp(tui);
141 expect(tui.screen.toText()).not.toContain("later words");
142 terminal.feed("r"); await settleApp(tui);
143 expect(tui.screen.toText()).toContain("later words");
144 tui.stop?.();
145 });
146
102147 test("past the cap, n says so and starts nothing", async () => {
103148 while (createConversation(app.db, alice.id).ok) { /* fill */ }
104149 const held = (app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(alice.id) as { n: number }).n;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs