Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/web/src/auto-workbench-title.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
47 changes: 47 additions & 0 deletions apps/web/src/auto-workbench-title.ts
Original file line number Diff line number Diff line change
@@ -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);
}
137 changes: 137 additions & 0 deletions apps/web/src/instant-agent-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
24 changes: 22 additions & 2 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions apps/web/test/new-workbench-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down
Loading