diff --git a/apps/web/src/auto-workbench-title.test.ts b/apps/web/src/auto-workbench-title.test.ts new file mode 100644 index 000000000..b8c239025 --- /dev/null +++ b/apps/web/src/auto-workbench-title.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { + AUTO_WORKBENCH_TITLE_MAX, + autoNameFromFirstMessage, + NEW_WORKBENCH_TITLE, + titleFromFirstMessage, +} from "./auto-workbench-title"; + +describe("titleFromFirstMessage (CL-6656)", () => { + test("trims and collapses whitespace into a single-line title", () => { + expect(titleFromFirstMessage(" Help me\nplan Q3 ")).toBe( + "Help me plan Q3", + ); + }); + + test("returns undefined for blank or whitespace-only input", () => { + expect(titleFromFirstMessage("")).toBeUndefined(); + expect(titleFromFirstMessage(" \n\t ")).toBeUndefined(); + }); + + test("keeps a short message intact", () => { + expect(titleFromFirstMessage("Draft a launch checklist")).toBe( + "Draft a launch checklist", + ); + }); + + test("truncates a long message at a word boundary with an ellipsis", () => { + const long = + "Help me write a detailed competitive analysis of every agent coding tool shipping this quarter"; + const title = titleFromFirstMessage(long); + expect(title).toBeDefined(); + if (title === undefined) return; + expect(title.endsWith("…")).toBe(true); + expect(title.length).toBeLessThanOrEqual(AUTO_WORKBENCH_TITLE_MAX + 1); + expect(title).not.toContain("shipping"); + }); +}); + +describe("autoNameFromFirstMessage (CL-6656)", () => { + test("names an ad-hoc New Workbench from the first message", () => { + expect( + autoNameFromFirstMessage(NEW_WORKBENCH_TITLE, "Plan the Q3 launch"), + ).toBe("Plan the Q3 launch"); + }); + + test("leaves prefab and already-renamed titles alone", () => { + expect( + autoNameFromFirstMessage("Code review", "Review the auth PR"), + ).toBeUndefined(); + expect( + autoNameFromFirstMessage("My research bench", "Dig into pricing"), + ).toBeUndefined(); + }); + + test("returns undefined when the first message has no usable text", () => { + expect( + autoNameFromFirstMessage(NEW_WORKBENCH_TITLE, " "), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/auto-workbench-title.ts b/apps/web/src/auto-workbench-title.ts new file mode 100644 index 000000000..a8ba1d022 --- /dev/null +++ b/apps/web/src/auto-workbench-title.ts @@ -0,0 +1,47 @@ +// CL-6656: ad-hoc ("Just start talking") benches mint as "New Workbench" and +// used to stay that way in the sidebar. Prefab templates already name the +// room after the template title; this helper turns the person's first message +// into a short sidebar title so blank benches get the same treatment once +// that message is known (at create) — without inventing a second rename API. +// Callers apply the result through `patchWorkbenchSettings` the same way the +// sidebar rename does (`chat/name`). + +/** Placeholder title for an untitled / blank mint — never a prefab name. */ +export const NEW_WORKBENCH_TITLE = "New Workbench"; + +/** Sidebar-friendly cap — long enough for a goal phrase, short enough to scan. */ +export const AUTO_WORKBENCH_TITLE_MAX = 48; + +/** + * Collapse a first user message into a short workbench title. + * Returns `undefined` when there is nothing worth naming with (blank / + * whitespace-only). Truncates at a word boundary when the cut would land + * mid-word past halfway, and appends an ellipsis when truncated. + */ +export function titleFromFirstMessage( + message: string, + maxLength: number = AUTO_WORKBENCH_TITLE_MAX, +): string | undefined { + const collapsed = message.trim().replace(/\s+/g, " "); + if (collapsed.length === 0) return undefined; + if (collapsed.length <= maxLength) return collapsed; + + const sliced = collapsed.slice(0, maxLength); + const lastSpace = sliced.lastIndexOf(" "); + const cut = + lastSpace > Math.floor(maxLength / 2) ? sliced.slice(0, lastSpace) : sliced; + return `${cut.replace(/[.,;:!?]+$/u, "")}…`; +} + +/** + * What an ad-hoc auto-name should PATCH as `chat/name`: the derived title when + * the room is still the generic "New Workbench" placeholder, otherwise + * `undefined` so callers leave prefab (and already-renamed) titles alone. + */ +export function autoNameFromFirstMessage( + currentTitle: string, + firstMessage: string, +): string | undefined { + if (currentTitle !== NEW_WORKBENCH_TITLE) return undefined; + return titleFromFirstMessage(firstMessage); +} diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index bcdc39d75..c13f3ca22 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -246,4 +246,141 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { // owner reported it from. expect(queryClient.getQueryState(staleQueryKey)?.isInvalidated).toBe(true); }); + + // CL-6656: the prompt-box path mints a blank "New Workbench" and used to + // leave that placeholder in the sidebar forever. With a known first + // message, create renames via the same `chat/name` settings PATCH the + // sidebar rename uses. + test("blank create with a first message renames off New Workbench via chat/name", async () => { + const navigated: string[] = []; + const calls = stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [assistantDefinitionWire], nextCursor: null }); + } + if (path.endsWith("/chat/workbenches")) { + return json({ + id: "chan-adhoc", + title: NEW_WORKBENCH_TITLE, + kind: "workbench", + pinned: false, + participants: [], + }); + } + if (path.endsWith("/chat/workbenches/chan-adhoc/messages")) { + return json({ + id: "msg-1", + createdAt: "2026-01-01T00:00:00.000Z", + sender: { name: "Sawyer", address: "principal-1" }, + parts: [{ kind: "text", text: "Plan the Q3 launch" }], + }); + } + if (path.endsWith("/chat/workbenches/chan-adhoc/settings")) { + return json({ + id: "chan-adhoc", + title: "Plan the Q3 launch", + kind: "workbench", + pinned: false, + participants: [], + settings: { "chat/name": "Plan the Q3 launch" }, + contextWindow: { value: 0, source: "inherit" }, + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + (to) => navigated.push(to), + newQueryClient(), + undefined, + "Plan the Q3 launch", + ); + + const settingsCall = calls.find((call) => + call.path.endsWith("/chat/workbenches/chan-adhoc/settings"), + ); + expect(settingsCall).toBeDefined(); + expect(JSON.parse(String(settingsCall?.init?.body))).toEqual({ + "chat/name": "Plan the Q3 launch", + }); + expect(navigated).toEqual(["/w/chan-adhoc"]); + }); + + test("prefab create with a first message does not overwrite the template title", async () => { + const calls = stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [assistantDefinitionWire], nextCursor: null }); + } + if (path.endsWith("/library/templates/code-review")) { + return json({ + id: "code-review", + content: serializeWorkbenchTemplateManifest(CODE_REVIEW_TEMPLATE), + }); + } + if (path.endsWith("/chat/workbenches")) { + return json({ + id: "chan-1", + title: "Code review", + kind: "workbench", + pinned: false, + participants: [], + }); + } + if (path.endsWith("/template-blocks/code-review/deploy")) { + return json({ id: "def-code-review-block", created: true }); + } + if (path.endsWith("/agent-definitions")) { + return json({ ...assistantDefinitionWire, id: "def-reviewer-1" }); + } + if (path.endsWith("/chat/workbenches/chan-1/invite")) { + return json({ + address: "agent:invited", + definitionId: "def-reviewer-1", + }); + } + if (path.endsWith("/chat/workbenches/chan-1/settings")) { + return json({ + id: "chan-1", + title: "Code review", + kind: "workbench", + pinned: false, + participants: [], + settings: {}, + contextWindow: { value: 0, source: "inherit" }, + }); + } + if (path.endsWith("/chat/workbenches/chan-1/messages")) { + return json({ + id: "msg-1", + createdAt: "2026-01-01T00:00:00.000Z", + sender: { name: "Sawyer", address: "principal-1" }, + parts: [{ kind: "text", text: "Review the auth PR" }], + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + await createWorkbenchFromTemplate( + "tnt_1", + "code-review", + () => {}, + newQueryClient(), + undefined, + "Review the auth PR", + ); + + const settingsBodies = calls + .filter((call) => call.path.endsWith("/chat/workbenches/chan-1/settings")) + .map((call) => JSON.parse(String(call.init?.body))); + expect( + settingsBodies.some( + (body) => + typeof body === "object" && + body !== null && + "chat/name" in body && + body["chat/name"] === "Review the auth PR", + ), + ).toBe(false); + }); }); diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index bda7317c1..3b56fdc8d 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -35,13 +35,17 @@ import { } from "./workbench-templates-api"; import { createAgentDefinition, listAgentDefinitions } from "./agents-api"; +import { + autoNameFromFirstMessage, + NEW_WORKBENCH_TITLE, +} from "./auto-workbench-title"; import { findMyraDefinition } from "./myra-workbench"; import { workbenchPath } from "./workbench-path"; const log = getLogger("web.instant-agent-create"); import type { WorkbenchTemplateId } from "./workbench-templates"; -export const NEW_WORKBENCH_TITLE = "New Workbench"; +export { NEW_WORKBENCH_TITLE }; /** * Marks the two precondition failures below as intentionally @@ -127,7 +131,10 @@ export type PickGithubRepos = (args: { * signed-in person's own opening message once the room and its * template participants exist, so it lands after the setup/template * greeting rather than racing it — Myra reads the room's actual intent - * as the next line, not the first. + * as the next line, not the first. For a blank / ad-hoc mint (CL-6656) + * that same text also renames the room off `NEW_WORKBENCH_TITLE` via + * `patchWorkbenchSettings` (`chat/name`), matching the sidebar rename + * path; prefab titles are left alone. */ export async function createWorkbenchFromTemplate( tenantId: string, @@ -242,6 +249,19 @@ export async function createWorkbenchFromTemplate( if (firstMessage !== undefined && firstMessage.trim() !== "") { await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, [])); + // CL-6656: blank / ad-hoc mints stay "New Workbench" until named. When the + // prompt box already supplied the opening message, rename via the same + // `chat/name` settings PATCH the sidebar rename uses — prefab titles + // (`manifest?.title`) are left alone by `autoNameFromFirstMessage`. + const autoTitle = autoNameFromFirstMessage(workbench.title, firstMessage); + if (autoTitle !== undefined) { + await patchWorkbenchSettings(tenantId, workbench.id, { + "chat/name": autoTitle, + }); + await queryClient.invalidateQueries({ + queryKey: workbenchesQueryKeyPrefix(tenantId), + }); + } } navigate(workbenchPath(workbench.id)); diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 4165bc220..9370b901a 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -204,6 +204,20 @@ function stubBlankCreate( onSendMessage?.(body); return json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" }); } + if ( + path.endsWith("/chat/workbenches/chan_new/settings") && + init?.method === "PATCH" + ) { + return json({ + id: "chan_new", + title: "Get our onboarding docs into shape", + kind: "workbench", + pinned: false, + participants: [], + settings: {}, + contextWindow: { value: 0, source: "inherit" }, + }); + } return undefined; }); }