diff --git a/README.md b/README.md index bf0b49a00..d9d0b7701 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ The recent channel chat transcript is supplied as bounded context for the next t even when those messages did not address the Planner. Saved member messages also maintain the document's title independently of conversation-derived cards. Weak or incidental discussion leaves the title alone; topic changes may refine a blank, -generated or Planner-drafted title, but never one a person set. This work obeys `AGENT`, ownership and implementation locks, and +generated or Planner-drafted title, but never one a person set or renamed, and never +while someone is renaming. This work obeys `AGENT`, ownership and implementation locks, and does not require Jev. A title job can seed a goal only before body prose exists. The first eligible model-backed action, including title maintenance, a Planner turn diff --git a/apps/server/src/agent/heading-tool.test.ts b/apps/server/src/agent/heading-tool.test.ts index e885a0f5a..cf92cc3cd 100644 --- a/apps/server/src/agent/heading-tool.test.ts +++ b/apps/server/src/agent/heading-tool.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import { QuestionnaireNode } from "@chopin/dialect"; -import { $nodesOfType } from "lexical"; +import { $createTextNode, $getRoot, $isElementNode, $nodesOfType } from "lexical"; +import * as Y from "yjs"; import { toolbox } from "./heading-tool.test-bridge"; import * as room from "../plan/room"; @@ -8,6 +9,7 @@ import * as Service from "../plan/service"; import { openPlan } from "../testing/plan"; import type { ConversationPlan } from "@chopin/protocol"; +import type { Socket } from "../wire"; const CARD = ` @@ -191,6 +193,131 @@ test("a member's rename after a draft is kept by the next draft", async () => { expect(room.project(context.plan.document)).toBe("# Member title\n\nExisting body.\n"); }); +test("a person's rename is never redrafted, even to a blank, generated or earlier draft", async () => { + for (let renamed of ["", "quiet-river", "First draft"]) { + let context = await opened("# \n\nExisting body.\n"); + headingJob(context.plan); + await headingTool(context).handler({ revision: 0, title: "First draft" }, {} as never); + await Service.exclusive(context.plan, () => Service.rename(context.plan, "Member title")); + await Service.exclusive(context.plan, () => Service.rename(context.plan, renamed)); + let kept = room.project(context.plan.document); + await Service.close(context.plan); + context.plan = await Service.open(context.channel.id, context.backend, context.server); + headingJob(context.plan); + let result = await headingTool(context).handler( + { revision: context.plan.revision, title: "Second draft" }, + {} as never, + ); + expect(JSON.parse(String(result))).toMatchObject({ ok: true, changed: false }); + expect(room.project(context.plan.document)).toBe(kept); + } +}); + +test("a draft waits while a person has the title open, and for a while after", async () => { + let context = await opened("# amber-river\n\nExisting body.\n"); + let person = {}; + let clock = spyOn(Date, "now"); + let at = Date.parse("2026-10-21T10:00:00.000Z"); + clock.mockImplementation(() => at); + try { + Service.titling(context.plan, person, { active: true }); + headingJob(context.plan); + let held = await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never); + expect(JSON.parse(String(held))).toMatchObject({ ok: true, changed: false }); + Service.titling(context.plan, person, { active: false }); + at += 30_000; + headingJob(context.plan); + await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never); + expect(room.project(context.plan.document)).toBe("# amber-river\n\nExisting body.\n"); + at += 31_000; + headingJob(context.plan); + let applied = await headingTool(context).handler( + { revision: 0, title: "Drafted" }, + {} as never, + ); + expect(JSON.parse(String(applied))).toMatchObject({ ok: true, title: "Drafted" }); + expect(room.project(context.plan.document)).toBe("# Drafted\n\nExisting body.\n"); + } finally { + clock.mockRestore(); + } +}); + +test("a person who typed in the title field holds drafts until they leave", async () => { + let context = await opened("# amber-river\n\nExisting body.\n"); + let person = {}; + let clock = spyOn(Date, "now"); + let at = Date.parse("2026-10-21T10:00:00.000Z"); + clock.mockImplementation(() => at); + try { + Service.titling(context.plan, person, { active: true, edited: true }); + Service.titling(context.plan, person, { active: false }); + at += 10 * 60_000; + headingJob(context.plan); + await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never); + expect(room.project(context.plan.document)).toBe("# amber-river\n\nExisting body.\n"); + Service.departed(context.plan, person as never); + at += 61_000; + headingJob(context.plan); + await headingTool(context).handler({ revision: 0, title: "Drafted" }, {} as never); + expect(room.project(context.plan.document)).toBe("# Drafted\n\nExisting body.\n"); + } finally { + clock.mockRestore(); + } +}); +test("a person's edit of the title in the document is never redrafted", async () => { + for (let typed of ["", "First draft"]) { + let context = await opened("# \n\nExisting body.\n"); + context.backend.documentTitles = true; + await Service.close(context.plan); + context.plan = await Service.open(context.channel.id, context.backend, context.server); + let plan = context.plan; + headingJob(plan); + await headingTool(context).handler({ revision: plan.revision, title: "Drafted" }, {} as never); + expect(room.project(plan.document)).toBe("# Drafted\n\nExisting body.\n"); + let peer = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + let vector = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let title = $getRoot().getFirstChild(); + if (!$isElementNode(title)) throw new Error("title missing"); + title.clear(); + if (typed) title.append($createTextNode(typed)); + }, { discrete: true }); + await room.settle(); + let ws = { + data: { canEdit: true, room: plan.id, handle: "ana", client: "peer" }, + send() {}, + publish() {}, + close() {}, + } as unknown as Socket; + Service.submit(plan, ws, { + kind: "plan:update", + ts: 0, + rid: "title", + id: "title", + epoch: plan.document.epoch, + update: Buffer.from(Y.encodeStateAsUpdate(peer.doc, vector)).toString("base64"), + }); + await Service.drain(plan); + peer.doc.destroy(); + let kept = room.project(plan.document); + expect(kept).toBe(`${typed ? `# ${typed}` : "#"}\n\nExisting body.\n`); + await Service.close(plan); + context.plan = await Service.open(context.channel.id, context.backend, context.server); + headingJob(context.plan); + let result = await headingTool(context).handler( + { revision: context.plan.revision, title: "Second draft" }, + {} as never, + ); + expect(JSON.parse(String(result))).toMatchObject({ ok: true, changed: false }); + expect(room.project(context.plan.document)).toBe(kept); + } +}); + test("unchanged friendly or blank titles are successful no-ops", async () => { for (let title of ["Friendly otter", ""]) { let context = await opened(`# ${title}\n`); diff --git a/apps/server/src/agent/heading-tool.ts b/apps/server/src/agent/heading-tool.ts index c8385c2f9..eee6e579b 100644 --- a/apps/server/src/agent/heading-tool.ts +++ b/apps/server/src/agent/heading-tool.ts @@ -10,7 +10,7 @@ import { import { parse } from "@chopin/dialect/parse"; import { serialize } from "@chopin/dialect/serialize"; -import { draftableTitle } from "../channels/document-title"; +import { draftableTitle, titleHeld } from "../channels/document-title"; import * as Comments from "../comments/service"; import * as edit from "../plan/edit"; import * as room from "../plan/room"; @@ -84,8 +84,9 @@ export async function draftHeading( if (revision !== plan.revision) throw new Error("stale; read_plan again"); let source = room.project(plan.document); let current = documentTitleText(parse(source)) ?? ""; - // A person's title is theirs: the draft may still seed the goal beneath it. - if (!draftableTitle(current, plan.plannerTitle)) title = current; + // A person's title is theirs, and so is one they are renaming right now: the draft may + // still seed the goal beneath it. + if (!draftableTitle(current, plan) || titleHeld(plan.titling, Date.now())) title = current; let retitled = title !== current; let next = withDocumentTitle(source, title); let body = parse(documentBodySource(source)); diff --git a/apps/server/src/channels/document-title.ts b/apps/server/src/channels/document-title.ts index 70dd813ea..08a3ecf3d 100644 --- a/apps/server/src/channels/document-title.ts +++ b/apps/server/src/channels/document-title.ts @@ -174,7 +174,30 @@ export function generatedTitle(value: string): boolean { return rest.length === 0 && adjectiveSet.has(adjective!) && nounSet.has(noun ?? ""); } -/** A draft may replace a blank or generated title, or the one a Planner last drafted. */ -export function draftableTitle(title: string, plannerTitle: string | undefined): boolean { - return !title || title === plannerTitle || generatedTitle(title); +/** + * A draft may replace a blank or generated title, or the one a Planner last drafted, until a + * person renames the document: their title is then theirs, whatever it is. + */ +export function draftableTitle( + title: string, + plan: { plannerTitle?: string; titleChosen?: boolean }, +): boolean { + return !plan.titleChosen && (!title || title === plan.plannerTitle || generatedTitle(title)); +} + +/** How long after a person last had the title open a drafted title still waits. */ +export const TITLE_HOLD_MS = 60_000; + +/** Who has the title open, or has typed in it, on which connection. Never persisted. */ +export type Titling = { + people: Map; + /** When someone last let go of the title. */ + releasedAt?: number; +}; + +/** A person is renaming, or about to: a drafted title must not race them. */ +export function titleHeld(titling: Titling | undefined, now: number): boolean { + if (!titling) return false; + for (let person of titling.people.values()) if (person.active || person.edited) return true; + return titling.releasedAt !== undefined && now - titling.releasedAt < TITLE_HOLD_MS; } diff --git a/apps/server/src/conversation-plan/job-prompts.ts b/apps/server/src/conversation-plan/job-prompts.ts index 1f7688173..b38ee3cf8 100644 --- a/apps/server/src/conversation-plan/job-prompts.ts +++ b/apps/server/src/conversation-plan/job-prompts.ts @@ -220,7 +220,7 @@ export const PROMPT_FOR: Record< transcript: plan.chat.entries, outline: outline(plan), title, - personal: !draftableTitle(title ?? "", plan.plannerTitle), + personal: !draftableTitle(title ?? "", plan), }); }, refine: (plan, job) => card(plan, job, "refine"), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 8c497a4a0..f9ec1eea8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1099,6 +1099,12 @@ async function receive(ws: Socket, raw: string): Promise { if (room.plan) Service.awareness(room.plan, ws, frame); return; + case "plan:titling": + if (room.plan && typeof frame.active === "boolean") { + Service.titling(room.plan, ws, { active: frame.active, edited: frame.edited === true }); + } + return; + case "plan:close": if (room.plan) Service.departed(room.plan, ws); return; diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index 6708969a1..c34900004 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -356,7 +356,13 @@ export function mark(target: Document): void { } export type Applied = - | { ok: true; seq: number; researchProjections: ResearchProjectionChange[] } + | { + ok: true; + seq: number; + researchProjections: ResearchProjectionChange[]; + /** The batch changed the title's text. */ + retitled?: true; + } | { ok: false; issues: string[] }; /** @@ -383,6 +389,7 @@ export async function apply( () => $getNodeByKey(target.titleKey!)?.getTextContent(), ); let researchProjections: ResearchProjectionChange[] = []; + let retitled = false; let held = open > 0 ? Buffer.byteLength(project(target)) : 0; try { for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE); @@ -400,6 +407,7 @@ export async function apply( : target.editor.getEditorState().read( () => $getNodeByKey(target.titleKey!)?.getTextContent(), ); + retitled = afterTitle !== beforeTitle; if ( afterTitle !== undefined && afterTitle !== beforeTitle && !validDocumentTitleText(afterTitle) ) { @@ -472,7 +480,12 @@ export async function apply( } target.seq += updates.length; - return { ok: true, seq: target.seq, researchProjections }; + return { + ok: true, + seq: target.seq, + researchProjections, + ...(retitled ? { retitled: true as const } : {}), + }; } /** diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index ac836df05..c0c5ee526 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -61,6 +61,7 @@ import type { Server } from "bun"; import type { ConversationPlan, Plan as Wire, Request, Sidebar } from "@chopin/protocol"; import type { Socket, SocketData } from "../wire"; import type { Presence } from "./presence"; +import type { Titling } from "../channels/document-title"; import type { Document } from "./room"; import type { Block } from "./edit"; import type { Brief, CreationOrigin } from "../mcp"; @@ -266,6 +267,10 @@ export type Plan = { pendingCardActions: PendingCardAction[]; /** The title a Planner last drafted; any other non-generated title is a person's to keep. */ plannerTitle?: string; + /** A person renamed the document or edited its title; no drafted title replaces theirs. */ + titleChosen?: true; + /** Who is renaming the document right now; a drafted title waits for them. */ + titling?: Titling; /** * Every questionnaire this plan has ever held, answered or not. @@ -354,6 +359,7 @@ type Sidecar = { workflowRuns?: NonNullable; initiativePaused?: boolean; plannerTitle?: string; + titleChosen?: true; }; type ChatView = Pick; @@ -394,6 +400,7 @@ function state(plan: Plan, chat: ChatView = plan.chat): Sidecar { ...(chat.runs?.length ? { workflowRuns: chat.runs } : {}), ...((chat.initiativePaused ?? plan.chat.initiativePaused) ? { initiativePaused: true } : {}), ...(plan.plannerTitle !== undefined ? { plannerTitle: plan.plannerTitle } : {}), + ...(plan.titleChosen ? { titleChosen: true as const } : {}), }; } @@ -587,6 +594,7 @@ function restoredState( if (Object.hasOwn(item, "workflowRuns")) expected.push("workflowRuns"); if (Object.hasOwn(item, "initiativePaused")) expected.push("initiativePaused"); if (Object.hasOwn(item, "plannerTitle")) expected.push("plannerTitle"); + if (Object.hasOwn(item, "titleChosen")) expected.push("titleChosen"); expected.sort(); if ( keys.length !== expected.length @@ -604,6 +612,7 @@ function restoredState( || !Array.isArray(item.transcript) || (Object.hasOwn(item, "initiativePaused") && typeof item.initiativePaused !== "boolean") || (Object.hasOwn(item, "plannerTitle") && typeof item.plannerTitle !== "string") + || (Object.hasOwn(item, "titleChosen") && item.titleChosen !== true) ) throw new Error("hosted channel has an invalid sidecar"); let execution = Object.hasOwn(item, "execution") ? restoreRun(item.execution, graph, item.revision) @@ -780,6 +789,7 @@ function restoredState( ...(workflowRuns.length > 0 ? { workflowRuns } : {}), ...(item.initiativePaused === true ? { initiativePaused: true } : {}), ...(typeof item.plannerTitle === "string" ? { plannerTitle: item.plannerTitle } : {}), + ...(item.titleChosen === true ? { titleChosen: true as const } : {}), }; } @@ -1527,6 +1537,7 @@ export async function open( conversationPlanPendingEffects: sidecar.conversationPlanPendingEffects ?? [], pendingCardActions: sidecar.pendingCardActions ?? [], ...(sidecar.plannerTitle !== undefined ? { plannerTitle: sidecar.plannerTitle } : {}), + ...(sidecar.titleChosen ? { titleChosen: true as const } : {}), outlines: new Map(), records: new Map( sidecar.questions.map(record => [record.id, Questions.normalizeRecord(record)]), @@ -1817,6 +1828,9 @@ async function commit(plan: Plan): Promise { ? room.enableTitle(plan.document, plan.persistence.committedTitle) : undefined; let previousRevision = plan.revision; + let previousChosen = plan.titleChosen; + // Only people send browser updates: a title they touched is theirs from now on. + if (outcome.retitled) plan.titleChosen = true; if (room.project(plan.document) !== plan.persistence.committedSource) plan.revision++; let merged = Y.mergeUpdates([ ...batch.map(item => item.update), @@ -1838,6 +1852,7 @@ async function commit(plan: Plan): Promise { } catch (err) { if (!(err instanceof ResearchProjectionConflict)) throw err; plan.revision = previousRevision; + plan.titleChosen = previousChosen; plan.records = previousRecords; plan.threads = previousThreads; await rejectBatch(plan, batch, ["research-reference-conflict"]); @@ -1991,6 +2006,7 @@ export async function publishStaged( plan.conversationPlanPendingEffects = candidate.conversationPlanPendingEffects; plan.conversationPlanEffects = candidate.conversationPlanEffects; plan.plannerTitle = candidate.plannerTitle; + if (candidate.titleChosen) plan.titleChosen = true; if (mutation) { try { broadcast(server, roomId, { @@ -2327,8 +2343,24 @@ export function awareness(plan: Plan, ws: Socket, msg: Wire.Awareness): void { relay(ws, { kind: "plan:awareness", ts: 0, epoch: plan.document.epoch, update: msg.update }); } +/** + * Remember whether a person has the title open, or has typed in it on this connection. A drafted + * title waits for them, and for a while after they let go. + */ +export function titling(plan: Plan, ws: object, state: { active: boolean; edited?: boolean }) { + let titling: Titling = plan.titling ??= { people: new Map() }; + let before = titling.people.get(ws); + let edited = !!before?.edited || state.edited === true; + if (before?.active && !state.active) titling.releasedAt = Date.now(); + titling.people.set(ws, { active: state.active, edited }); +} + /** Clear a departed member's cursors rather than leaving peers to time them out. */ export function departed(plan: Plan, ws: Socket): void { + let left = plan.titling?.people.get(ws); + if (left && plan.titling!.people.delete(ws) && (left.active || left.edited)) { + plan.titling!.releasedAt = Date.now(); + } let update = presence.drop(plan.presence, ws); if (!update) return; relay(ws, { @@ -2520,7 +2552,10 @@ export function placeExperimentReference( }); } -/** Caller holds the document queue. The H1, metadata and slug commit before publication. */ +/** + * Caller holds the document queue. The H1, metadata and slug commit before publication. Only + * people rename, directly or through their coding agent, so the title is theirs from then on. + */ export async function rename( plan: Plan, title: string, @@ -2545,6 +2580,7 @@ export async function rename( records: new Map(plan.records), threads: new Map(plan.threads), outlines: new Map(plan.outlines), + titleChosen: true, }; try { let outcome = edit.replace(candidate, plan.revision, next); diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 19e5dd480..efaedd887 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -110,6 +110,7 @@ import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { useNavigationDocument } from "./navigation-shell"; import { titleEdits } from "./title-edit"; +import { useTitlingReport } from "./title-presence"; import { peopleHere } from "./presence"; import { discardDecision, @@ -1247,6 +1248,12 @@ export function RoomWorkspace( let workspaceArchivedAt = archivedAt ?? metadata.archivedAt; let workspaceCanEdit = effectiveCanEdit && !workspaceArchivedAt; + useTitlingReport({ + wire: workspaceCanEdit ? wire : undefined, + room, + open: !!titleEdit && effectiveCanManage && !workspaceArchivedAt, + area: documentArea, + }); let editMode = documentEditMode({ canEdit: !!workspaceCanEdit && !implementation.locked, editing, diff --git a/apps/web/src/title-presence.ts b/apps/web/src/title-presence.ts new file mode 100644 index 000000000..184fdcf0b --- /dev/null +++ b/apps/web/src/title-presence.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; + +import type { RefObject } from "react"; +import type { Wire } from "./wire"; + +/** + * Tells the server while this person has the document title open, in its header field or the + * document's own heading, and whether they have typed in it: the Planner's drafted title then + * waits rather than racing them. + */ +export function useTitlingReport( + { wire, room, open, area }: { + wire: Wire | undefined; + room: string; + /** The header's title field is open. */ + open: boolean; + /** Holds this document's live editor. */ + area: RefObject; + }, +) { + let check = useRef<() => void>(undefined); + let opened = useRef(open); + opened.current = open; + + useEffect(() => { + if (!wire) return; + let live = true; + // What the server last heard on this connection. + let sent = { active: false, edited: false }; + let edited = false; + let active = () => { + if (opened.current) return true; + let focused = document.activeElement; + if (!focused) return false; + if (focused.id === `document-title-${room}`) return true; + let anchor = getSelection()?.anchorNode; + let heading = (anchor instanceof Element ? anchor : anchor?.parentElement) + ?.closest("[data-document-title]"); + return !!heading && focused.contains(heading) + && !!area.current?.querySelector("[data-live-document]")?.contains(heading); + }; + let report = (typed = false) => { + if (!live) return; + let now = active(); + if (typed && now) edited = true; + if (sent.active === now && sent.edited === edited) return; + sent = { active: now, edited }; + wire.send("plan:titling", { active: now, ...(edited ? { edited: true } : {}) }); + }; + let changed = () => report(); + let typed = () => report(true); + // Blur lands before the next element takes focus. + let left = () => setTimeout(changed); + check.current = changed; + // A reopened document is a new connection to the server, which knows nothing yet. + let off = wire.on("plan:open", () => { + sent = { active: false, edited: false }; + report(); + }); + document.addEventListener("selectionchange", changed); + document.addEventListener("focusin", changed); + document.addEventListener("focusout", left); + document.addEventListener("input", typed, true); + report(); + return () => { + off(); + document.removeEventListener("selectionchange", changed); + document.removeEventListener("focusin", changed); + document.removeEventListener("focusout", left); + document.removeEventListener("input", typed, true); + if (check.current === changed) check.current = undefined; + if (sent.active) wire.send("plan:titling", { active: false }); + live = false; + }; + }, [wire, room]); + + useEffect(() => check.current?.(), [open]); +} diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 9698ea45d..c8e8e5735 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -112,7 +112,14 @@ existing body prose. When the topic changes it may refine a blank title, the generated name of a document created without one, or the title a Planner last drafted (kept in the sidecar as `plannerTitle`). A title a person set, at creation, by renaming or by editing the heading, is never replaced; the job may -still seed a goal beneath it. +still seed a goal beneath it. Once a person renames the document or edits its +heading, the sidecar records `titleChosen` and no later draft replaces their title, +even a blank, a generated-looking name or the Planner's own earlier draft. +A drafted title also waits for a person who is renaming: while anyone has the +header's title field open or the caret in the heading (`plan:titling`), while +anyone still connected has typed in either, and for a minute after the last of +them lets go, the job keeps the current title. The drafted title is not offered +elsewhere; the next saved message drafts again. Missing credentials, an absent remote attachment and an implementation lock keep new work pending. Closing or detaching interrupts live work; reconnecting never replays an interrupted job. Under `remote`, title work uses only the attached owner diff --git a/e2e/document-title-naming.e2e.ts b/e2e/document-title-naming.e2e.ts index c59e549c8..6d1891367 100644 --- a/e2e/document-title-naming.e2e.ts +++ b/e2e/document-title-naming.e2e.ts @@ -87,3 +87,57 @@ test("a heading job keeps the title a person chose", async ({ join, seed, page, await ready(ana); await surfaces(ana, "E2E5 verify login resume"); }); + +function doneJobs(page: Page): () => number { + let jobs: ConversationPlan.Job[] = []; + page.on("websocket", socket => + socket.on("framereceived", ({ payload }) => { + if (typeof payload !== "string") return; + let frame = JSON.parse(payload) as { kind: string; jobs?: ConversationPlan.Job[] }; + if (frame.kind === "conversation-plan:jobs") jobs = frame.jobs ?? []; + })); + return () => jobs.filter(job => job.status === "done").length; +} + +test("a person typing a title keeps it while the Planner drafts one", async ({ join, seed, page, room }) => { + await seed("# quiet-river\n\nExisting body.\n"); + let done = doneJobs(page); + let ana = await join("ana"); + let bo = await join("bo"); + await ana.getByRole("group", { name: "Document: quiet-river", exact: true }) + .getByRole("button", { name: "Rename quiet-river", exact: true }).click(); + let field = ana.getByRole("textbox", { name: "Document title", exact: true }); + await field.fill("Login resume"); + await send(bo, "TITLE-TOPIC: Authentication choices"); + await expect.poll(done).toBe(1); + await surfaces(bo, "quiet-river"); + await expect(field).toHaveValue("Login resume"); + await field.press("Enter"); + for (let peer of [ana, bo]) await surfaces(peer, "Login resume"); + // Later conversation does not redraft a title a person renamed. + await send(bo, "TITLE-TOPIC: Offline retries"); + await expect.poll(done).toBe(2); + for (let peer of [ana, bo]) await surfaces(peer, "Login resume"); + await written(bo, room, "# Login resume\n\nExisting body.\n"); +}); + +test("a person with the caret in the document's title keeps it from a drafted title", async ({ join, seed, page, room }) => { + await seed("# quiet-river\n\nExisting body.\n"); + let done = doneJobs(page); + let ana = await join("ana"); + let bo = await join("bo"); + let heading = content(ana).getByRole("heading", { name: "quiet-river", exact: true }); + await heading.click(); + await send(bo, "TITLE-TOPIC: Authentication choices"); + await expect.poll(done).toBe(1); + await surfaces(bo, "quiet-river"); + await ana.keyboard.press("End"); + await ana.keyboard.press("Shift+Home"); + await ana.keyboard.type("Login resume"); + await written(bo, room, "# Login resume\n\nExisting body.\n"); + await content(ana).getByText("Existing body.").click(); + await send(bo, "TITLE-TOPIC: Offline retries"); + await expect.poll(done).toBe(2); + for (let peer of [ana, bo]) await surfaces(peer, "Login resume"); + await written(bo, room, "# Login resume\n\nExisting body.\n"); +}); diff --git a/packages/protocol/plan.d.ts b/packages/protocol/plan.d.ts index aaacaa60b..fcd3e5dcf 100644 --- a/packages/protocol/plan.d.ts +++ b/packages/protocol/plan.d.ts @@ -14,7 +14,7 @@ type KIND = Frame & { kind: K }; * text frame is legible in a network inspector when something goes wrong. */ export declare namespace Plan { - export type Incoming = Request | Request | Awareness | Close; + export type Incoming = Request | Request | Awareness | Titling | Close; export type Outgoing = | Open.Reply @@ -296,6 +296,13 @@ export declare namespace Plan { update: string; }; + /** + * This person has the document's title open for editing, in its header field or the + * document's own heading, and whether they have typed in it on this connection. A drafted + * title is not applied while anyone is renaming, nor for a short while after. Ephemeral. + */ + export type Titling = KIND<"plan:titling"> & { active: boolean; edited?: true }; + /** Leave the document. Presence is dropped; the room stays open. */ export type Close = KIND<"plan:close">;