Skip to content
Closed
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
18 changes: 9 additions & 9 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ column at a time:
bench, flat, most-recently-active first. There is no separate "channels"
vs. "chats" grouping the sidebar exposes to a person — every row is a
workbench.
- **"+ New Workbench" always creates.** It opens `/new` — the shipped
prompt-primary picker — never a picker of existing things to join.
Starting a new workbench is the one way in, whether the result is a
one-on-one conversation with an agent or a group conversation
with people and agents together.
- **Agents are templates.** Starting a new agent conversation means picking
an agent definition (a named, reusable capability) as the starting point
— the same definition can be launched into any number of separate
conversations, each with its own history and its own tenant.
- **"+ New Workbench" always creates a room.** It opens `/new` — the shipped
prompt-primary picker — never a picker of existing things to join, and
never reopens an agent's one conversation. Starting a new workbench mints
a fresh room; opening an agent (from Agents, Talk to Myra, and so on)
find-or-reopens that agent's existing conversation.
- **Agents are templates.** An agent definition is a named, reusable
capability. Opening an agent always find-or-reopens its one conversation
for this bench; "+" creates a separate room and invites participants into
it — including Myra — rather than minting another agent DM.
- The active workbench occupies the main column; a contextual panel beside
it carries account-wide surfaces (approvals, recent activity) that stay
visible regardless of which workbench is open.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/agent-chat-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("launchAgentChat", () => {
headers: { "content-type": "application/json" },
});

test("creates a chat for the given definitionId with no reuseExisting flag, and navigates to it", async () => {
test("opens a chat for the given definitionId and navigates to it", async () => {
const navigated: string[] = [];
const calls = stubFetch((path) => {
if (path.endsWith("/chat/workbenches")) {
Expand Down
28 changes: 15 additions & 13 deletions apps/web/src/agent-chat-launch.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// The one path from "an agent's definitionId" to "the person is in a
// fresh chat with it" — the same `POST /workbenches` call this app's every
// create path uses. `CreateAgentPanel`'s Settings → Agents entry point
// calls this on success so an explicitly-defined new agent never ends
// nowhere, and `instant-agent-create.ts` — THE one creation verb
// (CL-6138) — calls it against the account's default setup template.
// Always creates (CL-6089) — never the `reuseExisting` land-hop path,
// which is `default-agent-workbench.ts`'s own call, not this one.
// chat with it" — `openAgentConversation` find-or-reopens the agent's
// one conversation (CL-6981). `CreateAgentPanel`'s Settings → Agents
// entry point calls this on success so an explicitly-defined new agent
// never ends nowhere, and `instant-agent-create.ts` — THE one creation
// verb (CL-6138) — calls against the account's default setup template
// through `createWorkbench` with template fields, not this hop.

import { createWorkbench } from "@corbits/chat-ui";
import { createWorkbench, openAgentConversation } from "@corbits/chat-ui";

import { workbenchPath } from "./workbench-path";

Expand All @@ -17,10 +16,13 @@ export async function launchAgentChat(
navigate: (to: string) => void,
name?: string,
): Promise<void> {
const workbench = await createWorkbench(tenantId, {
kind: "chat",
definitionId,
...(name !== undefined ? { name } : {}),
});
const workbench =
name === undefined
? await openAgentConversation(tenantId, definitionId)
: await createWorkbench(tenantId, {
kind: "chat",
definitionId,
name,
});
navigate(workbenchPath(workbench.id));
}
3 changes: 1 addition & 2 deletions apps/web/src/agent-dm-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("openAgentDmChat", () => {
headers: { "content-type": "application/json" },
});

test("opens the agent's DM with reuseExisting and navigates to it", async () => {
test("opens the agent's conversation and navigates to it", async () => {
const navigated: string[] = [];
const calls = stubFetch((path) => {
if (path.endsWith("/chat/workbenches")) {
Expand All @@ -52,7 +52,6 @@ describe("openAgentDmChat", () => {
expect(JSON.parse(String(call?.init?.body))).toEqual({
kind: "chat",
definitionId: "wfd_outreach",
reuseExisting: true,
});
expect(navigated).toEqual(["/w/chan-dm-1"]);
});
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/agent-dm-launch.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// The one path from an agent definition's id to "the person is in their
// direct chat with it" (CL-6253) — the sidebar's agent rows are the one
// caller. Mirrors `agent-chat-launch.ts`'s shape exactly, but through
// `openAgentDm` (`kind: "chat"`, `reuseExisting: true`) rather than
// `createWorkbench` directly: the first click mints the DM, every later
// click finds the same workbench by `chat/definitionId`
// caller. Mirrors `agent-chat-launch.ts`'s shape exactly, through
// `openAgentConversation`: the first click mints the conversation, every
// later click finds the same workbench by `chat/definitionId`
// (`findExistingAgentChat` in `packages/chat/src/routes.ts`) instead of
// spawning a new one each time.

import { openAgentDm } from "@corbits/chat-ui";
import { openAgentConversation } from "@corbits/chat-ui";

import { workbenchPath } from "./workbench-path";

Expand All @@ -16,6 +15,6 @@ export async function openAgentDmChat(
definitionId: string,
navigate: (to: string) => void,
): Promise<void> {
const workbench = await openAgentDm(tenantId, definitionId);
const workbench = await openAgentConversation(tenantId, definitionId);
navigate(workbenchPath(workbench.id));
}
41 changes: 33 additions & 8 deletions apps/web/src/instant-agent-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
// The picker's "Create workbench" is clickable more than once per
// session (a second visit, a second row) — each click must mint its
// own, genuinely distinct workbench, never reopen or alias the last
// one it created.
// one it created. CL-6981: blank must POST kind=workbench without
// Myra's definitionId, or a second "+" would find-or-reopen her DM.
test("picking the same row twice in a row mints two distinct workbenches, not one reused", async () => {
let nextId = 0;
const navigated: string[] = [];
Expand All @@ -69,11 +70,17 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
return json({
id: `chan-${nextId}`,
title: NEW_WORKBENCH_TITLE,
kind: "chat",
kind: "workbench",
pinned: false,
participants: [],
});
}
if (/\/chat\/workbenches\/chan-\d+\/invite$/.test(path)) {
return json({
address: "agent:myra@room",
definitionId: "def-assistant",
});
}
throw new Error(`unexpected fetch: ${path}`);
});

Expand All @@ -99,9 +106,23 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {

// Blank ("Just start talking") has no template to name the bench
// after, so it keeps the generic title rather than something
// invented.
// invented. CL-6981: a room mint, never a Myra chat reopen.
const body = JSON.parse(String(createCalls[0]?.init?.body));
expect(body.name).toBe(NEW_WORKBENCH_TITLE);
expect(body).toEqual({
kind: "workbench",
name: NEW_WORKBENCH_TITLE,
});
expect(body).not.toHaveProperty("definitionId");

const inviteBodies = calls.filter((call) =>
/\/chat\/workbenches\/chan-\d+\/invite$/.test(call.path),
);
expect(inviteBodies).toHaveLength(2);
for (const invite of inviteBodies) {
expect(JSON.parse(String(invite.init?.body))).toEqual({
definitionId: "def-assistant",
});
}
});

// CL-6387 follow-up: picking a named template threw its own name away
Expand All @@ -126,7 +147,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
return json({
id: "chan-1",
title: "Code review",
kind: "chat",
kind: "workbench",
pinned: false,
participants: [],
});
Expand All @@ -148,7 +169,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
return json({
id: "chan-1",
title: "Code review",
kind: "chat",
kind: "workbench",
pinned: false,
participants: [],
settings: {},
Expand Down Expand Up @@ -176,7 +197,11 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
call.path.endsWith("/chat/workbenches"),
);
const createBody = JSON.parse(String(createCall?.init?.body));
expect(createBody.name).toBe(CODE_REVIEW_TEMPLATE.title);
expect(createBody).toEqual({
kind: "workbench",
name: CODE_REVIEW_TEMPLATE.title,
});
expect(createBody).not.toHaveProperty("definitionId");

const createAgentCalls = calls.filter((call) =>
call.path.endsWith("/agent-definitions"),
Expand All @@ -195,7 +220,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
const createdIds = createAgentCalls.map(
(_, index) => `def-reviewer-${index + 1}`,
);
expect(invitedIds.sort()).toEqual(createdIds.sort());
expect(invitedIds.sort()).toEqual(["def-assistant", ...createdIds].sort());
expect(navigated).toEqual(["/w/chan-1"]);

// CL-6594: a room this function navigates to must never carry a
Expand Down
67 changes: 34 additions & 33 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
// (CL-6486, superseding CL-6138's silent auto-mint) — opens the template
// picker (`pages/new-workbench-picker.tsx`, CL-6342) and calls
// `createWorkbenchFromTemplate` below once a row is chosen. It mints a
// fresh workbench against the account's default setup template (the same
// seeded `assistant` definition backing the home Myra workbench, which
// already opens with the setup greeting: "what do you want me around
// for?"). The conversation itself is what specializes the agent into
// whatever the person wants; the drafting and capability machinery already
// listens for that in-chat, so no definition is drafted or created up
// front here. Explicitly defining a brand-new agent template, with its own
// fresh `kind: "workbench"` room (never `kind: "chat"` + Myra's
// definitionId — that always find-or-reopens the one agent conversation,
// CL-6981), then invites Myra in as a participant. Named templates also
// create their roster and invite each non-Myra agent after the mint.
// Explicitly defining a brand-new agent template, with its own
// name/purpose/model/skills chosen up front, stays `CreateAgentPanel`'s job
// (Settings → Agents), unchanged.

Expand Down Expand Up @@ -99,22 +97,22 @@ export type PickGithubRepos = (args: {

/**
* The template picker's "Create workbench" action (CL-6344): mints a
* fresh chat, named after the picked template, against the account's
* default setup template (the seeded `assistant`/Myra definition),
* passing the picked row's id through as `templateId` so the room opens
* with that template's own intro (`packages/chat/src/routes.ts`'s
* `POST /workbenches` resolves it into the canned greeting). When the id
* names a real manifest (`workbenchTemplate`), this also creates its
* participant agent definitions, invites each into the room so the
* fresh `kind: "workbench"` room, named after the picked template (or
* `NEW_WORKBENCH_TITLE` for blank), then invites Myra in as a participant.
* Opening an agent via `kind: "chat"` + definitionId always find-or-reopens
* that agent's one conversation (CL-6981), so "+" must never mint that way —
* a second "+" would reopen Myra instead of creating another room.
*
* When the id names a real manifest (`workbenchTemplate`), this also creates
* its participant agent definitions, invites each into the room so the
* roster the greeting promises is the roster actually there (see
* `instantiateWorkbenchTemplate`'s own doc), and records its required
* connections as pending. A template id with no manifest yet (`blank`,
* "Just start talking") mints a plain untagged chat under the generic
* `NEW_WORKBENCH_TITLE`, exactly like before templates existed — there
* is no better name to give it. When `pickGithubRepos` is supplied and
* GitHub is already connected for this tenant, this also drives
* CL-6386's "select on new-workbench" step — see `PickGithubRepos`'s
* own doc.
* "Just start talking") mints a plain room under the generic
* `NEW_WORKBENCH_TITLE`, then invites Myra — there is no better name to
* give it. When `pickGithubRepos` is supplied and GitHub is already
* connected for this tenant, this also drives CL-6386's "select on
* new-workbench" step — see `PickGithubRepos`'s own doc.
*
* `queryClient` invalidates the workbenches list once every template
* participant has been invited (CL-6594) — `ChatWorkspace`'s own
Expand Down Expand Up @@ -170,10 +168,12 @@ export async function createWorkbenchFromTemplate(
manifest?.requiredConnections.includes("github") ?? false;

// GitHub already connected (established from the Plugins page, CL-6386)
// means this create flow can skip the in-room connect card entirely and
// go straight to repo selection once the workbench exists. Not yet
// connected keeps today's exact behaviour: the in-room card stays the
// just-in-time fallback.
// means this create flow can skip waiting on an in-room connect card
// and go straight to repo selection once the workbench exists. Not yet
// connected keeps the Plugins path as the just-in-time connect step;
// a room mint no longer auto-hosts Myra as `definitionId`, so the
// create-time `connectGithubRequiredFor` block (chat-mint only) is
// not posted here.
const githubAlreadyConnected =
requiresGithub && pickGithubRepos !== undefined
? (await listPluginsForTenant(tenantId)).some(
Expand All @@ -182,16 +182,16 @@ export async function createWorkbenchFromTemplate(
)
: false;

// A room, not a Myra DM reopen (CL-6981): `kind: "chat"` + definitionId
// always find-or-reopens that agent's one conversation.
const workbench = await createWorkbench(tenantId, {
kind: "chat",
definitionId: setupTemplate.id,
kind: "workbench",
name: manifest?.title ?? NEW_WORKBENCH_TITLE,
...(manifest !== undefined ? { templatePromise: manifest.promise } : {}),
...(requiresGithub && !githubAlreadyConnected
? { connectGithubRequiredFor: manifest?.title ?? "" }
: {}),
});

// Myra joins as an invited participant — never as the mint identity.
await inviteAgent(tenantId, workbench.id, setupTemplate.id);

if (githubAlreadyConnected && pickGithubRepos !== undefined) {
const state = await getConnectGithubState(tenantId, workbench.id);
if (state.kind === "connected" && state.repos.length > 0) {
Expand Down Expand Up @@ -239,11 +239,12 @@ export async function createWorkbenchFromTemplate(
for (const todo of result.webhookTriggerTodos) {
log.error(todo);
}
await queryClient.invalidateQueries({
queryKey: workbenchesQueryKeyPrefix(tenantId),
});
}

await queryClient.invalidateQueries({
queryKey: workbenchesQueryKeyPrefix(tenantId),
});

if (firstMessage !== undefined && firstMessage.trim() !== "") {
await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []));
}
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/myra-workbench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ describe("ensureMyraWorkbench", () => {
kind: "chat",
definitionId: "def-assistant",
name: "Myra",
reuseExisting: true,
});
expect(isMyraWorkbenchId("chat-1")).toBe(true);
});
Expand Down
Loading
Loading