Merge main conversation: land in it, led by the main bot
The only conflict was the onboarding imports: onboarding now creates the main bot, writes the profile and lands in the main conversation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
f359da0bf9
75
apps/api/src/main-conversation.ts
Normal file
75
apps/api/src/main-conversation.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import type { Actor, MainConversationRef } from "@rakazo/contracts";
|
||||||
|
import { hasMentionToken } from "@rakazo/core";
|
||||||
|
import {
|
||||||
|
ensureMainConversation,
|
||||||
|
findMainBotId,
|
||||||
|
joinMainConversation,
|
||||||
|
type PrismaClient,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the main conversation lives, repaired or created on the way. Null until onboarding has
|
||||||
|
* created the main bot: reading navigation must never create a bot by itself.
|
||||||
|
*/
|
||||||
|
export async function loadMainConversationRef(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<MainConversationRef | null> {
|
||||||
|
if (!(await findMainBotId(prisma, userId))) return null;
|
||||||
|
const conversation = await ensureMainConversation(prisma, userId);
|
||||||
|
return {
|
||||||
|
botId: conversation.leadBotId,
|
||||||
|
groupId: conversation.groupId,
|
||||||
|
spaceId: conversation.spaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNotMainBot(bot: { kind: string }, message: string) {
|
||||||
|
if (bot.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown or foreign groups fall through so the caller's own lookup reports them as before. */
|
||||||
|
export async function assertNotMainConversation(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
actor: Pick<Actor, "spaceId" | "userId">,
|
||||||
|
groupId: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
const group = await prisma.chatGroup.findFirst({
|
||||||
|
where: { id: groupId, spaceId: actor.spaceId, userId: actor.userId },
|
||||||
|
select: { kind: true },
|
||||||
|
});
|
||||||
|
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main conversation carries the main bot's name. */
|
||||||
|
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
|
||||||
|
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
|
||||||
|
* join it first, so the member mention rules route the message to them.
|
||||||
|
*/
|
||||||
|
export async function joinAddressedBots(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
actor: Pick<Actor, "spaceId" | "userId">,
|
||||||
|
input: { memberBotIds: string[]; text: string; botMentionIds: string[] },
|
||||||
|
) {
|
||||||
|
const candidates = await prisma.bot.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actor.userId,
|
||||||
|
spaceId: actor.spaceId,
|
||||||
|
archivedAt: null,
|
||||||
|
id: { notIn: input.memberBotIds },
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const addressed = candidates
|
||||||
|
.filter((bot) => input.botMentionIds.includes(bot.id) || hasMentionToken(input.text, bot.name))
|
||||||
|
.map((bot) => bot.id);
|
||||||
|
if (addressed.length === 0) return;
|
||||||
|
// Subscribers pick up the group.updated event with the send that follows.
|
||||||
|
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
|||||||
import {
|
import {
|
||||||
appendEventInTransaction,
|
appendEventInTransaction,
|
||||||
createThreadMessageInTransaction,
|
createThreadMessageInTransaction,
|
||||||
|
ensureMainConversation,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
type Prisma,
|
type Prisma,
|
||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
@ -13,7 +14,8 @@ import {
|
|||||||
/**
|
/**
|
||||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
* First-run conversational onboarding, seeded deterministically into the bot's
|
||||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
* thread: greeting, a focus choice, and available app cards the user authorizes
|
||||||
* inline. Focus must not rename the bot. No model tokens are spent.
|
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
||||||
|
* talks in the main conversation, where onboarding lands.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OnboardingDeps = {
|
type OnboardingDeps = {
|
||||||
@ -76,6 +78,10 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
|||||||
include: { thread: true },
|
include: { thread: true },
|
||||||
});
|
});
|
||||||
if (!bot?.thread) throw new IsolationError();
|
if (!bot?.thread) throw new IsolationError();
|
||||||
|
if (bot.kind === "main") {
|
||||||
|
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
|
||||||
|
return { bot, thread: { id: conversation.threadId } };
|
||||||
|
}
|
||||||
return { bot, thread: bot.thread };
|
return { bot, thread: bot.thread };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +95,7 @@ async function post(
|
|||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
role: "bot",
|
role: "bot",
|
||||||
blocks,
|
blocks,
|
||||||
|
botId: target.botId,
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
const event = await appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
@ -176,6 +183,7 @@ export async function promptFocus(
|
|||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
role: "bot",
|
role: "bot",
|
||||||
blocks,
|
blocks,
|
||||||
|
botId: target.botId,
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
const event = await appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
|
|||||||
@ -117,6 +117,7 @@ import {
|
|||||||
createSpaceForMember,
|
createSpaceForMember,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
deleteUnreferencedCredentialSecret,
|
deleteUnreferencedCredentialSecret,
|
||||||
|
ensureMainConversation,
|
||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
findDefaultVoiceCredential,
|
findDefaultVoiceCredential,
|
||||||
findModelCredential,
|
findModelCredential,
|
||||||
@ -135,6 +136,7 @@ import {
|
|||||||
projectWorkspaceDto,
|
projectWorkspaceDto,
|
||||||
releaseSpaceDeletionClaim,
|
releaseSpaceDeletionClaim,
|
||||||
renewSpaceDeletionClaim,
|
renewSpaceDeletionClaim,
|
||||||
|
requireMembership,
|
||||||
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
||||||
SpaceDeletionInProgressError,
|
SpaceDeletionInProgressError,
|
||||||
SpaceLimitError,
|
SpaceLimitError,
|
||||||
@ -157,6 +159,12 @@ import {
|
|||||||
} from "./computer-status.js";
|
} from "./computer-status.js";
|
||||||
import { listInbox } from "./inbox.js";
|
import { listInbox } from "./inbox.js";
|
||||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||||
|
import {
|
||||||
|
assertNotMainBot,
|
||||||
|
assertNotMainConversation,
|
||||||
|
loadMainConversationRef,
|
||||||
|
renameMainConversation,
|
||||||
|
} from "./main-conversation.js";
|
||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
@ -198,6 +206,7 @@ import {
|
|||||||
} from "./thread-message-pages.js";
|
} from "./thread-message-pages.js";
|
||||||
import {
|
import {
|
||||||
followUpGroupThreadMessage,
|
followUpGroupThreadMessage,
|
||||||
|
groupDefaultBotId,
|
||||||
reactToThreadMessage,
|
reactToThreadMessage,
|
||||||
resolveThreadTarget,
|
resolveThreadTarget,
|
||||||
sendThreadMessage,
|
sendThreadMessage,
|
||||||
@ -753,6 +762,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
thread,
|
thread,
|
||||||
routines,
|
routines,
|
||||||
spaces: navigation.spaces,
|
spaces: navigation.spaces,
|
||||||
|
main: navigation.main,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1016,6 +1026,23 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
throw mapSpaceLifecycleError(error);
|
throw mapSpaceLifecycleError(error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
ensureMain: authed.bots.ensureMain.handler(async ({ context }) => {
|
||||||
|
const conversation = await ensureMainConversation(deps.prisma, context.actor.userId).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
throw mapSpaceLifecycleError(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// The main bot lives in the home space, which may not be the space of this request.
|
||||||
|
const actor =
|
||||||
|
conversation.spaceId === context.actor.spaceId
|
||||||
|
? context.actor
|
||||||
|
: await requireMembership(deps.prisma, context.actor.userId, conversation.spaceId);
|
||||||
|
const bot = (await repos.listBots(actor)).find(
|
||||||
|
(candidate) => candidate.id === conversation.leadBotId,
|
||||||
|
);
|
||||||
|
if (!bot) throw new IsolationError();
|
||||||
|
return bot;
|
||||||
|
}),
|
||||||
duplicate: authed.bots.duplicate.handler(async ({ context, input }) => {
|
duplicate: authed.bots.duplicate.handler(async ({ context, input }) => {
|
||||||
const source = await repos.getBot(context.actor, input.botId);
|
const source = await repos.getBot(context.actor, input.botId);
|
||||||
const duplicate = await repos
|
const duplicate = await repos
|
||||||
@ -1191,6 +1218,9 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
allowedBuiltinTools: input.allowedBuiltinTools,
|
allowedBuiltinTools: input.allowedBuiltinTools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
|
||||||
|
await renameMainConversation(deps.prisma, context.actor.userId, input.name);
|
||||||
|
}
|
||||||
const bots = await repos.listBots(context.actor);
|
const bots = await repos.listBots(context.actor);
|
||||||
const bot = bots.find((b) => b.id === input.botId);
|
const bot = bots.find((b) => b.id === input.botId);
|
||||||
if (!bot) throw new IsolationError();
|
if (!bot) throw new IsolationError();
|
||||||
@ -1265,6 +1295,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
archive: authed.bots.archive.handler(async ({ context, input }) => {
|
archive: authed.bots.archive.handler(async ({ context, input }) => {
|
||||||
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
|
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
|
||||||
|
assertNotMainBot(bot, "The main bot can't be archived.");
|
||||||
await archiveBot(
|
await archiveBot(
|
||||||
{
|
{
|
||||||
prisma: deps.prisma,
|
prisma: deps.prisma,
|
||||||
@ -1287,6 +1318,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
remove: authed.bots.remove.handler(async ({ context, input }) => {
|
remove: authed.bots.remove.handler(async ({ context, input }) => {
|
||||||
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
|
const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true });
|
||||||
|
assertNotMainBot(bot, "The main bot can't be deleted.");
|
||||||
await destroyBot(
|
await destroyBot(
|
||||||
{
|
{
|
||||||
prisma: deps.prisma,
|
prisma: deps.prisma,
|
||||||
@ -1403,6 +1435,14 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
});
|
});
|
||||||
if (!section) throw new IsolationError();
|
if (!section) throw new IsolationError();
|
||||||
}
|
}
|
||||||
|
if (input.botIds) {
|
||||||
|
await assertNotMainConversation(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor,
|
||||||
|
input.groupId,
|
||||||
|
"Bots join the main conversation when you address them.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const updated = await groupRepos.updateGroup(context.actor, input);
|
const updated = await groupRepos.updateGroup(context.actor, input);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
updated.cancelledRunIds.map((runId) =>
|
updated.cancelledRunIds.map((runId) =>
|
||||||
@ -1412,6 +1452,12 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return updated.group;
|
return updated.group;
|
||||||
}),
|
}),
|
||||||
archive: authed.groups.archive.handler(async ({ context, input }) => {
|
archive: authed.groups.archive.handler(async ({ context, input }) => {
|
||||||
|
await assertNotMainConversation(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor,
|
||||||
|
input.groupId,
|
||||||
|
"The main conversation can't be archived.",
|
||||||
|
);
|
||||||
const archived = await groupRepos.archiveGroup(context.actor, input.groupId);
|
const archived = await groupRepos.archiveGroup(context.actor, input.groupId);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
archived.cancelledRunIds.map((runId) =>
|
archived.cancelledRunIds.map((runId) =>
|
||||||
@ -1455,6 +1501,12 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
remove: authed.groups.remove.handler(async ({ context, input }) => {
|
remove: authed.groups.remove.handler(async ({ context, input }) => {
|
||||||
|
await assertNotMainConversation(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor,
|
||||||
|
input.groupId,
|
||||||
|
"The main conversation can't be deleted.",
|
||||||
|
);
|
||||||
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
|
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
|
||||||
const cleanup = await Promise.allSettled(
|
const cleanup = await Promise.allSettled(
|
||||||
removed.artifactStorageKeys.map((storageKey) =>
|
removed.artifactStorageKeys.map((storageKey) =>
|
||||||
@ -1568,7 +1620,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
clear: authed.threads.clear.handler(async ({ context, input }) => {
|
clear: authed.threads.clear.handler(async ({ context, input }) => {
|
||||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||||
const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
|
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
|
||||||
if (!contextBotId) throw new IsolationError();
|
if (!contextBotId) throw new IsolationError();
|
||||||
const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
|
const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
|
||||||
spaceId: context.actor.spaceId,
|
spaceId: context.actor.spaceId,
|
||||||
@ -4860,6 +4912,8 @@ async function spaceNavigationDto(
|
|||||||
repos: ReturnType<typeof createRepos>,
|
repos: ReturnType<typeof createRepos>,
|
||||||
groupRepos: ReturnType<typeof createGroupRepos>,
|
groupRepos: ReturnType<typeof createGroupRepos>,
|
||||||
): Promise<SpaceNavigation> {
|
): Promise<SpaceNavigation> {
|
||||||
|
// First, so a main conversation created here is part of the lists below.
|
||||||
|
const main = await loadMainConversationRef(deps.prisma, actor.userId);
|
||||||
const currentSpace = await deps.prisma.space.findUnique({
|
const currentSpace = await deps.prisma.space.findUnique({
|
||||||
where: { id: actor.spaceId },
|
where: { id: actor.spaceId },
|
||||||
select: { organizationId: true },
|
select: { organizationId: true },
|
||||||
@ -4989,6 +5043,7 @@ async function spaceNavigationDto(
|
|||||||
botSections: sectionsFor(membership.spaceId),
|
botSections: sectionsFor(membership.spaceId),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
main,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
cancelSupersededQueuedRuns,
|
cancelSupersededQueuedRuns,
|
||||||
followUpGroupThreadMessage,
|
followUpGroupThreadMessage,
|
||||||
|
groupDefaultBotId,
|
||||||
reactToThreadMessage,
|
reactToThreadMessage,
|
||||||
resolveGroupFollowUpBotId,
|
resolveGroupFollowUpBotId,
|
||||||
sendThreadMessage,
|
sendThreadMessage,
|
||||||
@ -1660,6 +1661,44 @@ describe("resolveGroupFollowUpBotId", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("bot-b");
|
).toBe("bot-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends an unaddressed follow-up to the lead instead of the first member", () => {
|
||||||
|
expect(
|
||||||
|
resolveGroupFollowUpBotId({
|
||||||
|
text: "continue",
|
||||||
|
members,
|
||||||
|
activeRuns: [],
|
||||||
|
defaultBotId: "bot-b",
|
||||||
|
}),
|
||||||
|
).toBe("bot-b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupDefaultBotId", () => {
|
||||||
|
const group = {
|
||||||
|
kind: "group",
|
||||||
|
groupId: "group-1",
|
||||||
|
threadId: "thread-1",
|
||||||
|
groupName: "Group",
|
||||||
|
groupKind: "main",
|
||||||
|
members: [],
|
||||||
|
memberBotIds: ["bot-a", "bot-b"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
it("prefers the lead while it is a member", () => {
|
||||||
|
expect(
|
||||||
|
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: "bot-b" }),
|
||||||
|
).toBe("bot-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first member without a lead or after the lead left", () => {
|
||||||
|
expect(
|
||||||
|
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: null }),
|
||||||
|
).toBe("bot-a");
|
||||||
|
expect(
|
||||||
|
groupDefaultBotId({ ...group, memberBotIds: [...group.memberBotIds], leadBotId: "gone" }),
|
||||||
|
).toBe("bot-a");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("followUpGroupThreadMessage", () => {
|
describe("followUpGroupThreadMessage", () => {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@raka
|
|||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
GROUP_MEMBER_MIN,
|
GROUP_MEMBER_MIN,
|
||||||
|
type GroupKind,
|
||||||
type GroupMember,
|
type GroupMember,
|
||||||
type MessageBlock,
|
type MessageBlock,
|
||||||
type MessageReaction,
|
type MessageReaction,
|
||||||
@ -40,6 +41,7 @@ import {
|
|||||||
resolveSendAttachments,
|
resolveSendAttachments,
|
||||||
} from "./artifacts.js";
|
} from "./artifacts.js";
|
||||||
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
|
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
|
||||||
|
import { joinAddressedBots } from "./main-conversation.js";
|
||||||
import { withSerializableRetry } from "./serializable-retry.js";
|
import { withSerializableRetry } from "./serializable-retry.js";
|
||||||
import { loadMessagePage } from "./thread-message-pages.js";
|
import { loadMessagePage } from "./thread-message-pages.js";
|
||||||
|
|
||||||
@ -55,10 +57,20 @@ export type ThreadTarget =
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
|
groupKind: GroupKind;
|
||||||
|
/** Answers when nobody is addressed; null for ordinary groups. */
|
||||||
|
leadBotId: string | null;
|
||||||
members: GroupMember[];
|
members: GroupMember[];
|
||||||
memberBotIds: string[];
|
memberBotIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The member that answers when nobody is addressed: the group's lead, else its first member. */
|
||||||
|
export function groupDefaultBotId(target: Extract<ThreadTarget, { kind: "group" }>) {
|
||||||
|
return target.leadBotId && target.memberBotIds.includes(target.leadBotId)
|
||||||
|
? target.leadBotId
|
||||||
|
: target.memberBotIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten text for excerpt comparison. The parent blocks hold markdown
|
* Flatten text for excerpt comparison. The parent blocks hold markdown
|
||||||
* source while the selection captures rendered text, so structural syntax
|
* source while the selection captures rendered text, so structural syntax
|
||||||
@ -344,7 +356,9 @@ async function lockAndLoadGroupMembers(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!group || group.members.length < GROUP_MEMBER_MIN) throw new IsolationError();
|
// The main conversation starts with only its lead.
|
||||||
|
const minimum = group?.kind === "main" ? 1 : GROUP_MEMBER_MIN;
|
||||||
|
if (!group || group.members.length < minimum) throw new IsolationError();
|
||||||
return group.members.map((member) => ({
|
return group.members.map((member) => ({
|
||||||
botId: member.bot.id,
|
botId: member.bot.id,
|
||||||
name: member.bot.name,
|
name: member.bot.name,
|
||||||
@ -383,6 +397,8 @@ export async function resolveThreadTarget(
|
|||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
threadId: group.thread.id,
|
threadId: group.thread.id,
|
||||||
groupName: group.name,
|
groupName: group.name,
|
||||||
|
groupKind: group.kind === "main" ? "main" : "group",
|
||||||
|
leadBotId: group.leadBotId,
|
||||||
members,
|
members,
|
||||||
memberBotIds: members.map((member) => member.botId),
|
memberBotIds: members.map((member) => member.botId),
|
||||||
};
|
};
|
||||||
@ -563,6 +579,7 @@ export async function threadSnapshot(
|
|||||||
groupId: target.groupId,
|
groupId: target.groupId,
|
||||||
groupName: target.groupName,
|
groupName: target.groupName,
|
||||||
members: target.members,
|
members: target.members,
|
||||||
|
...(target.leadBotId ? { leadBotId: target.leadBotId } : {}),
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
cursor: core.last?.seq ?? -1,
|
cursor: core.last?.seq ?? -1,
|
||||||
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
|
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
|
||||||
@ -683,6 +700,13 @@ export async function sendThreadMessage(
|
|||||||
) {
|
) {
|
||||||
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
|
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
if (target.kind === "group" && target.groupKind === "main") {
|
||||||
|
await joinAddressedBots(deps.prisma, actor, {
|
||||||
|
memberBotIds: target.memberBotIds,
|
||||||
|
text: input.text ?? "",
|
||||||
|
botMentionIds: splitMentionTargets(input.mentions).botMentionIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
// The excerpt is rendered text while blocks hold markdown source, so it
|
// The excerpt is rendered text while blocks hold markdown source, so it
|
||||||
// can't be substring-verified verbatim — enforce the cap here, then check
|
// can't be substring-verified verbatim — enforce the cap here, then check
|
||||||
// the flattened form against the parent inside the transaction.
|
// the flattened form against the parent inside the transaction.
|
||||||
@ -828,6 +852,7 @@ export async function sendThreadMessage(
|
|||||||
text: input.text ?? "",
|
text: input.text ?? "",
|
||||||
members: members.map((member) => ({ id: member.botId, name: member.name })),
|
members: members.map((member) => ({ id: member.botId, name: member.name })),
|
||||||
explicitMentions: mentionTargets.botMentionIds,
|
explicitMentions: mentionTargets.botMentionIds,
|
||||||
|
defaultBotId: target.leadBotId,
|
||||||
});
|
});
|
||||||
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
|
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
|
||||||
{ prisma: tx },
|
{ prisma: tx },
|
||||||
@ -953,12 +978,13 @@ export async function sendThreadMessage(
|
|||||||
* Pick the group member bot that should receive a follow-up. Prefer a waiting
|
* Pick the group member bot that should receive a follow-up. Prefer a waiting
|
||||||
* ask/takeover on any member over the first member's merely-busy run — the
|
* ask/takeover on any member over the first member's merely-busy run — the
|
||||||
* router's old members[0] shortcut let a fresh run on bot A bury an unanswered
|
* router's old members[0] shortcut let a fresh run on bot A bury an unanswered
|
||||||
* ask on bot B.
|
* ask on bot B. Unaddressed follow-ups go to the lead when the group has one.
|
||||||
*/
|
*/
|
||||||
export function resolveGroupFollowUpBotId(input: {
|
export function resolveGroupFollowUpBotId(input: {
|
||||||
text: string;
|
text: string;
|
||||||
members: Array<{ botId: string; name: string }>;
|
members: Array<{ botId: string; name: string }>;
|
||||||
activeRuns: Array<{ botId: string; status: string; createdAt: Date }>;
|
activeRuns: Array<{ botId: string; status: string; createdAt: Date }>;
|
||||||
|
defaultBotId?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
const memberBotIds = new Set(input.members.map((member) => member.botId));
|
const memberBotIds = new Set(input.members.map((member) => member.botId));
|
||||||
const memberRuns = input.activeRuns
|
const memberRuns = input.activeRuns
|
||||||
@ -974,6 +1000,7 @@ export function resolveGroupFollowUpBotId(input: {
|
|||||||
text: input.text,
|
text: input.text,
|
||||||
members: input.members.map((member) => ({ id: member.botId, name: member.name })),
|
members: input.members.map((member) => ({ id: member.botId, name: member.name })),
|
||||||
explicitMentions: [],
|
explicitMentions: [],
|
||||||
|
defaultBotId: input.defaultBotId,
|
||||||
})[0];
|
})[0];
|
||||||
if (mentioned) return mentioned;
|
if (mentioned) return mentioned;
|
||||||
const fallback = input.members[0]?.botId;
|
const fallback = input.members[0]?.botId;
|
||||||
@ -999,7 +1026,12 @@ export async function followUpGroupThreadMessage(
|
|||||||
select: { id: true, botId: true, status: true, createdAt: true },
|
select: { id: true, botId: true, status: true, createdAt: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
const botId = resolveGroupFollowUpBotId({ text: input.text, members, activeRuns });
|
const botId = resolveGroupFollowUpBotId({
|
||||||
|
text: input.text,
|
||||||
|
members,
|
||||||
|
activeRuns,
|
||||||
|
defaultBotId: target.leadBotId,
|
||||||
|
});
|
||||||
const blocks = [{ kind: "text" as const, text: input.text }];
|
const blocks = [{ kind: "text" as const, text: input.text }];
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
@ -1085,7 +1117,7 @@ export async function reactToThreadMessage(
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (existing) return { eventSeq: null };
|
if (existing) return { eventSeq: null };
|
||||||
const botId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
|
const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
|
||||||
if (!botId) throw new IsolationError();
|
if (!botId) throw new IsolationError();
|
||||||
const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }];
|
const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }];
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { expect, type Page, test } from "@playwright/test";
|
|||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||||
|
|
||||||
/** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */
|
/** Activity rows sit above `[data-sidebar-group]` bots; match their aria-label. */
|
||||||
|
/** Activity rows only, not the sidebar's conversation entries for the same bot. */
|
||||||
function activityRow(page: Page, botName: string) {
|
function activityRow(page: Page, botName: string) {
|
||||||
return page.locator("aside").getByRole("button", {
|
return page.locator("aside").getByRole("button", {
|
||||||
name: new RegExp(`^${botName}, (?!1:1 conversation)`),
|
name: new RegExp(`^${botName}, (?!1:1 conversation|main conversation)`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
import {
|
||||||
|
activeBotId,
|
||||||
|
captureScreenshot,
|
||||||
|
completeOnboarding,
|
||||||
|
createNamedBot,
|
||||||
|
rpc,
|
||||||
|
signup,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `bot-organize-${stamp}@rakazo.test`, "password12", "Test User");
|
await signup(page, `bot-organize-${stamp}@rakazo.test`, "password12", "Test User");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
await page.goto("/app");
|
// The main bot is reached through the main conversation; specialists are organized here.
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await createNamedBot(page, "Scout");
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
const bot = sidebar.getByRole("button", { name: /^Chief/ });
|
const bot = sidebar.getByRole("button", { name: /^Scout/ });
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
|
await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
|
||||||
await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toContainText("Chief");
|
await expect(sidebar.locator('[data-sidebar-group="pinned"]')).toContainText("Scout");
|
||||||
await captureScreenshot(page, testInfo, "pinned-bots");
|
await captureScreenshot(page, testInfo, "pinned-bots");
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
@ -32,12 +39,12 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
|||||||
|
|
||||||
const projects = sidebar.locator('[data-sidebar-group^="section:"]');
|
const projects = sidebar.locator('[data-sidebar-group^="section:"]');
|
||||||
await expect(projects).toContainText("Projects");
|
await expect(projects).toContainText("Projects");
|
||||||
await expect(projects).toContainText("Chief");
|
await expect(projects).toContainText("Scout");
|
||||||
await captureScreenshot(page, testInfo, "bot-sections");
|
await captureScreenshot(page, testInfo, "bot-sections");
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(projects).toContainText("Projects");
|
await expect(projects).toContainText("Projects");
|
||||||
await expect(projects).toContainText("Chief");
|
await expect(projects).toContainText("Scout");
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
@ -58,17 +65,24 @@ test("pinned bots and sidebar sections persist", async ({ page }, testInfo) => {
|
|||||||
.getByRole("menu", { name: "Move to folder", exact: true })
|
.getByRole("menu", { name: "Move to folder", exact: true })
|
||||||
.getByRole("menuitem", { name: "Unassigned", exact: true })
|
.getByRole("menuitem", { name: "Unassigned", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Chief");
|
await expect(sidebar.locator('[data-sidebar-group="unassigned"]')).toContainText("Scout");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bots can be reordered by drag or keyboard and keep that order", async ({ page }) => {
|
test("bots can be reordered by drag or keyboard and keep that order", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `bot-reorder-${stamp}@rakazo.test`, "password12", "Bot Order");
|
await signup(page, `bot-reorder-${stamp}@rakazo.test`, "password12", "Bot Order");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
await page.goto("/app");
|
// The roster lists specialists; the main bot is reached through the main conversation.
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
const leadId = (
|
||||||
|
await rpc<{ id: string }>(page, "bots/create", {
|
||||||
const chiefId = activeBotId(page);
|
name: "Lead",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
})
|
||||||
|
).id;
|
||||||
const alpha = await rpc<{ id: string }>(page, "bots/create", {
|
const alpha = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
name: "Alpha",
|
name: "Alpha",
|
||||||
title: "",
|
title: "",
|
||||||
@ -91,7 +105,7 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
|
|||||||
const rows = sidebar.locator("[data-roster-bot-id]");
|
const rows = sidebar.locator("[data-roster-bot-id]");
|
||||||
const order = () =>
|
const order = () =>
|
||||||
rows.evaluateAll((items) => items.map((item) => item.getAttribute("data-roster-bot-id")));
|
rows.evaluateAll((items) => items.map((item) => item.getAttribute("data-roster-bot-id")));
|
||||||
await expect.poll(order).toEqual([chiefId, alpha.id, beta.id]);
|
await expect.poll(order).toEqual([leadId, alpha.id, beta.id]);
|
||||||
|
|
||||||
let releaseStaleList!: () => void;
|
let releaseStaleList!: () => void;
|
||||||
let markStaleListReady!: () => void;
|
let markStaleListReady!: () => void;
|
||||||
@ -126,20 +140,20 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
|
|||||||
);
|
);
|
||||||
await sidebar
|
await sidebar
|
||||||
.locator(`[data-roster-bot-id="${beta.id}"]`)
|
.locator(`[data-roster-bot-id="${beta.id}"]`)
|
||||||
.dragTo(sidebar.locator(`[data-roster-bot-id="${chiefId}"]`));
|
.dragTo(sidebar.locator(`[data-roster-bot-id="${leadId}"]`));
|
||||||
await reorderSaved;
|
await reorderSaved;
|
||||||
releaseStaleList();
|
releaseStaleList();
|
||||||
await staleListDelivered;
|
await staleListDelivered;
|
||||||
await expect.poll(order).toEqual([beta.id, chiefId, alpha.id]);
|
await expect.poll(order).toEqual([beta.id, leadId, alpha.id]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect.poll(order).toEqual([beta.id, chiefId, alpha.id]);
|
await expect.poll(order).toEqual([beta.id, leadId, alpha.id]);
|
||||||
|
|
||||||
const betaRow = sidebar.locator(`[data-roster-bot-id="${beta.id}"]`);
|
const betaRow = sidebar.locator(`[data-roster-bot-id="${beta.id}"]`);
|
||||||
await betaRow.focus();
|
await betaRow.focus();
|
||||||
await page.keyboard.press("Alt+ArrowDown");
|
await page.keyboard.press("Alt+ArrowDown");
|
||||||
await expect.poll(order).toEqual([chiefId, beta.id, alpha.id]);
|
await expect.poll(order).toEqual([leadId, beta.id, alpha.id]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect.poll(order).toEqual([chiefId, beta.id, alpha.id]);
|
await expect.poll(order).toEqual([leadId, beta.id, alpha.id]);
|
||||||
|
|
||||||
let releaseRejectedReorder!: () => void;
|
let releaseRejectedReorder!: () => void;
|
||||||
let markRejectedReorderStarted!: () => void;
|
let markRejectedReorderStarted!: () => void;
|
||||||
@ -160,7 +174,7 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
|
|||||||
);
|
);
|
||||||
await sidebar
|
await sidebar
|
||||||
.locator(`[data-roster-bot-id="${alpha.id}"]`)
|
.locator(`[data-roster-bot-id="${alpha.id}"]`)
|
||||||
.dragTo(sidebar.locator(`[data-roster-bot-id="${chiefId}"]`));
|
.dragTo(sidebar.locator(`[data-roster-bot-id="${leadId}"]`));
|
||||||
await rejectedReorderStarted;
|
await rejectedReorderStarted;
|
||||||
|
|
||||||
const queuedReorderSaved = page.waitForResponse(
|
const queuedReorderSaved = page.waitForResponse(
|
||||||
@ -171,9 +185,9 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p
|
|||||||
.dragTo(sidebar.locator(`[data-roster-bot-id="${alpha.id}"]`));
|
.dragTo(sidebar.locator(`[data-roster-bot-id="${alpha.id}"]`));
|
||||||
releaseRejectedReorder();
|
releaseRejectedReorder();
|
||||||
await queuedReorderSaved;
|
await queuedReorderSaved;
|
||||||
await expect.poll(order).toEqual([beta.id, alpha.id, chiefId]);
|
await expect.poll(order).toEqual([beta.id, alpha.id, leadId]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect.poll(order).toEqual([beta.id, alpha.id, chiefId]);
|
await expect.poll(order).toEqual([beta.id, alpha.id, leadId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("chat composer controls are vertically centered", async ({ page }) => {
|
test("chat composer controls are vertically centered", async ({ page }) => {
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
import { captureScreenshot, completeOnboarding, createNamedBot, signup } from "./helpers";
|
||||||
|
|
||||||
test("titled sidebar section expands and collapses", async ({ page }, testInfo) => {
|
test("titled sidebar section expands and collapses", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `sidebar-collapse-${stamp}@rakazo.test`, "password12", "Test User");
|
await signup(page, `sidebar-collapse-${stamp}@rakazo.test`, "password12", "Test User");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
await page.goto("/app");
|
// The main bot is reached through the main conversation; specialists live in sections.
|
||||||
await page.waitForURL(/\/app\/[^/]+$/);
|
await createNamedBot(page, "Scout");
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
const bot = sidebar.getByRole("button", { name: /^Chief/ });
|
const bot = sidebar.getByRole("button", { name: /^Scout/ });
|
||||||
|
|
||||||
await bot.click({ button: "right" });
|
await bot.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
await page.getByRole("menuitem", { name: "Move to folder", exact: true }).hover();
|
||||||
@ -23,7 +23,7 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
|
|||||||
|
|
||||||
const projects = sidebar.locator('[data-sidebar-group^="section:"]');
|
const projects = sidebar.locator('[data-sidebar-group^="section:"]');
|
||||||
await expect(projects).toContainText("Projects");
|
await expect(projects).toContainText("Projects");
|
||||||
await expect(projects).toContainText("Chief");
|
await expect(projects).toContainText("Scout");
|
||||||
|
|
||||||
const toggle = projects.getByRole("button", { name: /Collapse Projects|Expand Projects/ });
|
const toggle = projects.getByRole("button", { name: /Collapse Projects|Expand Projects/ });
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
@ -32,7 +32,7 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
|
|||||||
await sidebar.getByPlaceholder("Search").hover();
|
await sidebar.getByPlaceholder("Search").hover();
|
||||||
await captureScreenshot(page, testInfo, "sidebar-section-expanded");
|
await captureScreenshot(page, testInfo, "sidebar-section-expanded");
|
||||||
|
|
||||||
// Hover header with Chief selected underneath — outer edges must match.
|
// Hover header with Scout selected underneath — outer edges must match.
|
||||||
await bot.click();
|
await bot.click();
|
||||||
await toggle.hover();
|
await toggle.hover();
|
||||||
const headerBox = await toggle.boundingBox();
|
const headerBox = await toggle.boundingBox();
|
||||||
@ -45,11 +45,11 @@ test("titled sidebar section expands and collapses", async ({ page }, testInfo)
|
|||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect(projects.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(projects.getByRole("button", { name: /^Scout/ })).toHaveCount(0);
|
||||||
await sidebar.getByPlaceholder("Search").hover();
|
await sidebar.getByPlaceholder("Search").hover();
|
||||||
await captureScreenshot(page, testInfo, "sidebar-section-collapsed");
|
await captureScreenshot(page, testInfo, "sidebar-section-collapsed");
|
||||||
|
|
||||||
await toggle.click();
|
await toggle.click();
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
await expect(projects.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(projects.getByRole("button", { name: /^Scout/ })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,8 +49,6 @@ test("Starten in an onboarded project opens the group create flow", async ({ pag
|
|||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await selectProjectSpace(projekte, "Website");
|
await selectProjectSpace(projekte, "Website");
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await page.goto("/onboarding");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@ -83,12 +81,15 @@ test("archiving the only bot keeps Starten visible in the project sidebar", asyn
|
|||||||
await dialog.getByLabel("Name").fill("Side");
|
await dialog.getByLabel("Name").fill("Side");
|
||||||
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await page.goto("/onboarding");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const [chief] = await rpc<Array<{ id: string; name: string }>>(page, "bots/list", {});
|
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
expect(chief?.name).toMatch(/^Chief/);
|
name: "Side bot",
|
||||||
await rpc(page, "bots/archive", { botId: chief!.id });
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await rpc(page, "bots/archive", { botId: sideBot.id });
|
||||||
|
|
||||||
await page.goto("/app");
|
await page.goto("/app");
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
activeBotId,
|
activeBotId,
|
||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
|
createNamedBot,
|
||||||
openUserSettings,
|
openUserSettings,
|
||||||
realSandboxTimeout,
|
realSandboxTimeout,
|
||||||
rpc,
|
rpc,
|
||||||
@ -298,12 +299,14 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
await captureScreenshot(page, testInfo, "11d-provider-emulators");
|
await captureScreenshot(page, testInfo, "11d-provider-emulators");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Close integrations settings" }).click();
|
await page.getByRole("button", { name: "Close integrations settings" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Close integrations settings" })).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByText("Chief").first().click();
|
// Agent computer toggles the panel, so only open it when it is closed.
|
||||||
const gear = page.getByRole("button", { name: "Show settings" });
|
if ((await sidePanel.getAttribute("data-panel")) !== "computer") {
|
||||||
if (!(await gear.isVisible().catch(() => false))) {
|
|
||||||
await page.getByTitle("Agent computer").click();
|
await page.getByTitle("Agent computer").click();
|
||||||
}
|
}
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
||||||
|
const gear = page.getByRole("button", { name: "Show settings" });
|
||||||
await gear.click();
|
await gear.click();
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await page.getByRole("button", { name: "Export" }).click();
|
await page.getByRole("button", { name: "Export" }).click();
|
||||||
@ -314,10 +317,17 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
await expect(settings.getByRole("button", { name: "Delete bot" })).toHaveCount(0);
|
await expect(settings.getByRole("button", { name: "Delete bot" })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Close panel" }).click();
|
await page.getByRole("button", { name: "Close panel" }).click();
|
||||||
|
|
||||||
await page.locator("aside").first().getByRole("button", { name: /Chief/ }).first().click({
|
// The main bot cannot be archived or deleted; a specialist's menu offers both.
|
||||||
button: "right",
|
await createNamedBot(page, "Scout");
|
||||||
});
|
await page
|
||||||
const botMenu = page.getByRole("menu", { name: "Actions for Chief" });
|
.locator("aside")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: /^Scout/ })
|
||||||
|
.first()
|
||||||
|
.click({
|
||||||
|
button: "right",
|
||||||
|
});
|
||||||
|
const botMenu = page.getByRole("menu", { name: "Actions for Scout" });
|
||||||
await expect(botMenu.getByRole("menuitem", { name: "Archive" })).toBeVisible();
|
await expect(botMenu.getByRole("menuitem", { name: "Archive" })).toBeVisible();
|
||||||
await botMenu.getByRole("menuitem", { name: "Delete" }).click();
|
await botMenu.getByRole("menuitem", { name: "Delete" }).click();
|
||||||
await expect(page.getByRole("radio", { name: /Keep memories/ })).toBeChecked();
|
await expect(page.getByRole("radio", { name: /Keep memories/ })).toBeChecked();
|
||||||
@ -358,10 +368,6 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
|
|||||||
});
|
});
|
||||||
await captureScreenshot(page, testInfo, "13-spawned-bot");
|
await captureScreenshot(page, testInfo, "13-spawned-bot");
|
||||||
|
|
||||||
await page
|
|
||||||
.locator("[data-sidebar-group]")
|
|
||||||
.getByRole("button", { name: /^Chief/ })
|
|
||||||
.click();
|
|
||||||
await composer.fill("keep working until I stop you");
|
await composer.fill("keep working until I stop you");
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible({
|
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible({
|
||||||
@ -422,7 +428,7 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
|
|||||||
await page.getByPlaceholder("Password").fill("password12");
|
await page.getByPlaceholder("Password").fill("password12");
|
||||||
await page.getByRole("button", { name: "Continue with email" }).click();
|
await page.getByRole("button", { name: "Continue with email" }).click();
|
||||||
await page.waitForURL(/\/app/, { timeout: 20_000 });
|
await page.waitForURL(/\/app/, { timeout: 20_000 });
|
||||||
await expect(sidebarBotButton(page, /^Chief/)).toBeVisible();
|
await expect(page.getByRole("button", { name: "Chief, main conversation" })).toBeVisible();
|
||||||
await expect(sidebarBotButton(page, /Scout/)).toBeVisible();
|
await expect(sidebarBotButton(page, /Scout/)).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "15-restored-session");
|
await captureScreenshot(page, testInfo, "15-restored-session");
|
||||||
});
|
});
|
||||||
@ -433,41 +439,47 @@ test("bot context menu pins, duplicates, edits, and confirms deletion", async ({
|
|||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `menu-${stamp}@rakazo.test`, "password12", "Menu");
|
await signup(page, `menu-${stamp}@rakazo.test`, "password12", "Menu");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
// The main bot is reached through the main conversation; specialists carry the full menu.
|
||||||
|
await createNamedBot(page, "Scout");
|
||||||
|
|
||||||
const chief = page.getByRole("button", { name: /Chief/ }).first();
|
const scout = page
|
||||||
await chief.click({ button: "right" });
|
.locator("aside")
|
||||||
await expect(page.getByRole("menu", { name: "Actions for Chief" })).toBeVisible();
|
.first()
|
||||||
|
.getByRole("button", { name: /^Scout/ })
|
||||||
|
.first();
|
||||||
|
await scout.click({ button: "right" });
|
||||||
|
await expect(page.getByRole("menu", { name: "Actions for Scout" })).toBeVisible();
|
||||||
await expect(page.getByRole("menuitem", { name: "Edit Profile" })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Edit Profile" })).toBeVisible();
|
||||||
await expect(page.getByRole("menuitem", { name: "Duplicate" })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Duplicate" })).toBeVisible();
|
||||||
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "16-bot-context-menu");
|
await captureScreenshot(page, testInfo, "16-bot-context-menu");
|
||||||
await page.getByRole("menuitem", { name: "Mark as Unread" }).click();
|
await page.getByRole("menuitem", { name: "Mark as Unread" }).click();
|
||||||
|
|
||||||
// Chief is the open bot, so the auto-read on window focus must not undo the manual mark.
|
// Scout is the open bot, so the auto-read on window focus must not undo the manual mark.
|
||||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||||
await chief.click({ button: "right" });
|
await scout.click({ button: "right" });
|
||||||
await expect(page.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible();
|
||||||
await page.getByRole("menuitem", { name: "Mark as Read" }).click();
|
await page.getByRole("menuitem", { name: "Mark as Read" }).click();
|
||||||
|
|
||||||
await chief.click({ button: "right" });
|
await scout.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
|
await page.getByRole("menuitem", { name: "Pin", exact: true }).click();
|
||||||
|
|
||||||
await chief.click({ button: "right" });
|
await scout.click({ button: "right" });
|
||||||
await expect(page.getByRole("menuitem", { name: "Unpin", exact: true })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "Unpin", exact: true })).toBeVisible();
|
||||||
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
await page.getByRole("menuitem", { name: "Duplicate" }).click();
|
||||||
await expect(page.getByText("Chief copy").first()).toBeVisible();
|
await expect(page.getByText("Scout copy").first()).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "17-pinned-and-duplicated-bot");
|
await captureScreenshot(page, testInfo, "17-pinned-and-duplicated-bot");
|
||||||
|
|
||||||
const copy = page.getByRole("button", { name: /Chief copy/ }).first();
|
const copy = page.getByRole("button", { name: /Scout copy/ }).first();
|
||||||
await copy.click({ button: "right" });
|
await copy.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||||
await expect(page.getByRole("alertdialog", { name: "Delete Chief copy?" })).toBeVisible();
|
await expect(page.getByRole("alertdialog", { name: "Delete Scout copy?" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "18-delete-confirmation");
|
await captureScreenshot(page, testInfo, "18-delete-confirmation");
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
await chief.click({ button: "right" });
|
await scout.click({ button: "right" });
|
||||||
await page.getByRole("menuitem", { name: "Edit Profile" }).click();
|
await page.getByRole("menuitem", { name: "Edit Profile" }).click();
|
||||||
await expect(page.locator("label:has-text('Name') input")).toHaveValue("Chief");
|
await expect(page.locator("label:has-text('Name') input")).toHaveValue("Scout");
|
||||||
await captureScreenshot(page, testInfo, "19-edit-profile");
|
await captureScreenshot(page, testInfo, "19-edit-profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -87,7 +87,15 @@ async function visible(locator: Locator, timeout: number) {
|
|||||||
return locator.isVisible({ timeout }).catch(() => false);
|
return locator.isVisible({ timeout }).catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
/**
|
||||||
|
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
|
||||||
|
* is set, it then opens the main bot's own thread, where most specs start.
|
||||||
|
*/
|
||||||
|
export async function completeOnboarding(
|
||||||
|
page: Page,
|
||||||
|
testInfo?: TestInfo,
|
||||||
|
options: { mainConversation?: boolean } = {},
|
||||||
|
) {
|
||||||
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 });
|
||||||
|
|
||||||
if (page.url().includes("/onboarding")) {
|
if (page.url().includes("/onboarding")) {
|
||||||
@ -125,8 +133,8 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
|||||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan step auto-advances to first bot creation before landing in /app.
|
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
|
||||||
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
|
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||||||
await waitForChatComposer(page);
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
@ -134,6 +142,16 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
|||||||
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
||||||
await captureScreenshot(page, testInfo, "06-onboarding-complete");
|
await captureScreenshot(page, testInfo, "06-onboarding-complete");
|
||||||
}
|
}
|
||||||
|
if (!options.mainConversation) await openMainBotThread(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main bot's own thread stays reachable by URL; the sidebar lists the main conversation. */
|
||||||
|
export async function openMainBotThread(page: Page) {
|
||||||
|
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||||||
|
await page.goto(`/app/${main.id}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForURL(new RegExp(`/app/${main.id}$`), { timeout: 20_000 });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
return main.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signup(
|
export async function signup(
|
||||||
|
|||||||
@ -107,7 +107,8 @@ test("direct MCP connects a catalog result without asking for a URL and assigns
|
|||||||
const createGate = new Promise<void>((resolve) => {
|
const createGate = new Promise<void>((resolve) => {
|
||||||
releaseCreate = resolve;
|
releaseCreate = resolve;
|
||||||
});
|
});
|
||||||
await page.route("**/rpc/bots/create", async (route) => {
|
// Onboarding creates the main bot, which then gets the connected server.
|
||||||
|
await page.route("**/rpc/bots/ensureMain", async (route) => {
|
||||||
createCalls += 1;
|
createCalls += 1;
|
||||||
await createGate;
|
await createGate;
|
||||||
await route.continue();
|
await route.continue();
|
||||||
|
|||||||
94
apps/web/e2e/main-conversation.spec.ts
Normal file
94
apps/web/e2e/main-conversation.spec.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
|
|
||||||
|
type Snapshot = {
|
||||||
|
leadBotId?: string;
|
||||||
|
members?: Array<{ botId: string }>;
|
||||||
|
messages: Array<{ role: string; botId?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("onboarding lands in the main conversation, its lead answers, and addressed bots join", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `main-conversation-${stamp}@rakazo.test`, "password12", "Main Conversation");
|
||||||
|
await completeOnboarding(page, testInfo, { mainConversation: true });
|
||||||
|
|
||||||
|
const mainUrl = page.url();
|
||||||
|
const groupId = new URL(mainUrl).pathname.split("/").at(-1)!;
|
||||||
|
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
||||||
|
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
||||||
|
const sidebar = page.locator("aside").first();
|
||||||
|
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
|
||||||
|
await expect(mainEntry).toBeVisible();
|
||||||
|
// The main bot is reached through the main conversation, not listed again.
|
||||||
|
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("oo-group-stage")).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "main-conversation-landing");
|
||||||
|
|
||||||
|
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });
|
||||||
|
const botMessages = async (botId: string) =>
|
||||||
|
(await snapshot()).messages.filter(
|
||||||
|
(message) => message.role === "bot" && message.botId === botId,
|
||||||
|
).length;
|
||||||
|
expect((await snapshot()).leadBotId).toBe(chief.id);
|
||||||
|
|
||||||
|
// Nobody addressed: the main bot answers.
|
||||||
|
const chiefBefore = await botMessages(chief.id);
|
||||||
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
|
await composer.fill("What should we look at first?");
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect.poll(() => botMessages(chief.id), { timeout: 60_000 }).toBeGreaterThan(chiefBefore);
|
||||||
|
const transcript = page.getByTestId("transcript");
|
||||||
|
await expect(transcript).toContainText(/on it|handled/i);
|
||||||
|
|
||||||
|
// A same-space bot that is not a member yet joins when addressed and answers here.
|
||||||
|
const researcher = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name: "Researcher",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
await expect(sidebar.locator(`[data-roster-bot-id="${researcher.id}"]`)).toBeVisible();
|
||||||
|
await composer.fill("@Res");
|
||||||
|
await page.getByRole("option", { name: "@Researcher", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("mention-chip").filter({ hasText: "Researcher" })).toBeVisible();
|
||||||
|
await composer.fill(`${await composer.inputValue()}find three sources`);
|
||||||
|
await composer.press("Enter");
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await snapshot()).members?.map((member) => member.botId).sort(), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toEqual([chief.id, researcher.id].sort());
|
||||||
|
await expect.poll(() => botMessages(researcher.id), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||||
|
const researcherReply = transcript
|
||||||
|
.locator("[data-message-id]")
|
||||||
|
.filter({ hasText: /on it|handled/i })
|
||||||
|
.filter({ has: page.getByText("Researcher", { exact: true }) })
|
||||||
|
.first();
|
||||||
|
await expect(researcherReply).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "main-conversation-specialist-joined");
|
||||||
|
|
||||||
|
// From another space the entry is still first and leads back to the home space.
|
||||||
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await dialog.getByLabel("Name").fill("Launch");
|
||||||
|
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
|
||||||
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
|
.not.toBe(homeSpaceId);
|
||||||
|
await expect(mainEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await captureScreenshot(page, testInfo, "main-conversation-from-another-space");
|
||||||
|
await mainEntry.click();
|
||||||
|
await page.waitForURL(mainUrl);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
|
.toBe(homeSpaceId);
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
await expect(researcherReply).toBeVisible();
|
||||||
|
});
|
||||||
@ -67,7 +67,7 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `hover-actions-${stamp}@rakazo.test`, "password12", "Hover Actions");
|
await signup(page, `hover-actions-${stamp}@rakazo.test`, "password12", "Hover Actions");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
|
||||||
const transcript = page.getByTestId("transcript");
|
const transcript = page.getByTestId("transcript");
|
||||||
@ -383,7 +383,7 @@ test.describe("touch message actions", () => {
|
|||||||
|
|
||||||
test("More exposes actions without simulated hover", async ({ page }, testInfo) => {
|
test("More exposes actions without simulated hover", async ({ page }, testInfo) => {
|
||||||
await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions");
|
await signup(page, `touch-actions-${Date.now()}@rakazo.test`, "password12", "Touch Actions");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
expect(
|
expect(
|
||||||
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
|
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|||||||
@ -112,8 +112,8 @@ test("picker rows explain groups and spaces", async ({ page }, testInfo) => {
|
|||||||
test("later bot waits before showing the focus card; sending cancels it", async ({ page }) => {
|
test("later bot waits before showing the focus card; sending cancels it", async ({ page }) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
|
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
// First bot from onboarding shows the focus card immediately.
|
// The main bot shows the focus card immediately in the main conversation.
|
||||||
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.clock.install();
|
await page.clock.install();
|
||||||
|
|||||||
@ -10,7 +10,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin");
|
await signup(page, `onboarding-${stamp}@rakazo.test`, "password12", "Robin");
|
||||||
await completeOnboarding(page);
|
// The main bot greets the user in the main conversation, where onboarding lands.
|
||||||
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
|
||||||
@ -27,7 +28,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
await page.getByTestId("focus-choice-day").click();
|
await page.getByTestId("focus-choice-day").click();
|
||||||
// The focus step suggests apps but must not rename the bot: the name the
|
// The focus step suggests apps but must not rename the bot: the name the
|
||||||
// user chose during creation ("Chief") is preserved.
|
// user chose during creation ("Chief") is preserved.
|
||||||
await expect(page.locator("main").getByText("Chief", { exact: true })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId("bot-settings-trigger").getByText("Chief", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
|
||||||
@ -92,7 +96,7 @@ test("focus choice suggests apps and preserves a completed connection", async ({
|
|||||||
|
|
||||||
test("choice refresh failures leave options available for retry", async ({ page }) => {
|
test("choice refresh failures leave options available for retry", async ({ page }) => {
|
||||||
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
|
await signup(page, `choice-refresh-${Date.now()}@rakazo.test`, "password12", "Choice Retry");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||||
const choice = page.getByTestId("focus-choice-day");
|
const choice = page.getByTestId("focus-choice-day");
|
||||||
await expect(choice).toBeEnabled();
|
await expect(choice).toBeEnabled();
|
||||||
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.
|
// Keep the existing choice rendered while its save succeeds and navigation refresh fails.
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
import type { Bot, Routine } from "@rakazo/contracts";
|
import type { Bot, Routine } from "@rakazo/contracts";
|
||||||
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
import {
|
||||||
|
activeBotId,
|
||||||
|
captureScreenshot,
|
||||||
|
completeOnboarding,
|
||||||
|
createNamedBot,
|
||||||
|
rpc,
|
||||||
|
signup,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
async function addScheduleTrigger(page: Page, freq: string) {
|
async function addScheduleTrigger(page: Page, freq: string) {
|
||||||
await page.getByRole("button", { name: "Add trigger" }).click();
|
await page.getByRole("button", { name: "Add trigger" }).click();
|
||||||
@ -191,7 +198,8 @@ test("switching bots while a routine save is pending does not reopen stale state
|
|||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch");
|
await signup(page, `routine-switch-${stamp}@rakazo.test`, "password12", "Routine Switch");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
const firstBotId = activeBotId(page);
|
// Both bots are roster entries; the main bot is reached through the main conversation.
|
||||||
|
const firstBotId = await createNamedBot(page, "First");
|
||||||
const secondBot = await rpc<Bot>(page, "bots/create", {
|
const secondBot = await rpc<Bot>(page, "bots/create", {
|
||||||
name: "Second",
|
name: "Second",
|
||||||
title: "",
|
title: "",
|
||||||
@ -289,7 +297,7 @@ test("switching bots while a routine save is pending does not reopen stale state
|
|||||||
);
|
);
|
||||||
|
|
||||||
const botList = page.locator("aside").first();
|
const botList = page.locator("aside").first();
|
||||||
await botList.getByRole("button", { name: /^Chief/ }).click();
|
await botList.getByRole("button", { name: /^First/ }).click();
|
||||||
await staleListIntercepted;
|
await staleListIntercepted;
|
||||||
await botList.getByRole("button", { name: /^Second/ }).click();
|
await botList.getByRole("button", { name: /^Second/ }).click();
|
||||||
await page.waitForURL(new RegExp(`/app/${secondBot.id}$`));
|
await page.waitForURL(new RegExp(`/app/${secondBot.id}$`));
|
||||||
|
|||||||
@ -99,9 +99,8 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
|||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
||||||
.toBe(supportSpaceId);
|
.toBe(supportSpaceId);
|
||||||
await page.goto("/onboarding");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
|
// The main conversation stays the first entry while another space is open.
|
||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||||
@ -119,9 +118,7 @@ test("spaces stay invisible by default and chat creation requires approval", asy
|
|||||||
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a new space auto-completes onboarding and can be deleted from its menu", async ({
|
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => {
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
|
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
@ -131,26 +128,18 @@ test("a new space auto-completes onboarding and can be deleted from its menu", a
|
|||||||
const dialog = page.getByRole("dialog", { name: "New space" });
|
const dialog = page.getByRole("dialog", { name: "New space" });
|
||||||
await dialog.getByLabel("Name").fill("Temporary");
|
await dialog.getByLabel("Name").fill("Temporary");
|
||||||
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
||||||
await page.waitForURL(/\/onboarding/);
|
|
||||||
|
|
||||||
// A new empty space walks onboarding again, then lands in Chief.
|
// Onboarding runs once per user: a new space opens directly, with no bot of its own.
|
||||||
await completeOnboarding(page);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await expect(page).toHaveURL(/\/app\//);
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
|
||||||
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
|
||||||
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
|
||||||
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "new-space-after-onboarding");
|
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
|
||||||
|
await captureScreenshot(page, testInfo, "new-space-empty");
|
||||||
|
|
||||||
// Spaces can only be deleted when empty — remove auto-created Chief first.
|
// The empty space stays selectable instead of trapping the user.
|
||||||
const [temporaryChief] = await rpc<Array<{ id: string }>>(page, "bots/list", {});
|
|
||||||
expect(temporaryChief).toBeTruthy();
|
|
||||||
await rpc(page, "bots/remove", { botId: temporaryChief!.id, deleteMemories: false });
|
|
||||||
await page.goto("/app");
|
|
||||||
await page.waitForURL(/\/app(?:\/)?$/);
|
|
||||||
|
|
||||||
// The emptied space stays selectable instead of trapping the user.
|
|
||||||
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
|
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
|
||||||
|
|
||||||
// Deletion is discoverable through the visible Space actions menu.
|
// Deletion is discoverable through the visible Space actions menu.
|
||||||
@ -179,35 +168,39 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner");
|
await signup(page, `spaces-empty-${stamp}@rakazo.test`, "password12", "Space Owner");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
const [chief] = await rpc<Array<{ id: string }>>(page, "bots/list", {});
|
|
||||||
expect(chief).toBeTruthy();
|
|
||||||
await rpc(page, "bots/archive", { botId: chief!.id });
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
|
||||||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
|
||||||
|
|
||||||
const sidebar = page.locator("aside").first();
|
const sidebar = page.locator("aside").first();
|
||||||
// No active bot thread after archiving the only bot — shell shows Archived, not Message.
|
// The main bot cannot be archived, so the archived section comes from a specialist.
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toHaveCount(0);
|
const helper = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name: "Helper",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await rpc(page, "bots/archive", { botId: helper.id });
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(sidebar.getByRole("button", { name: /^Archived/ })).toBeVisible({
|
await expect(sidebar.getByRole("button", { name: /^Archived/ })).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await openNewSpace(page);
|
await openNewSpace(page);
|
||||||
const dialog = page.getByRole("dialog", { name: "New space" });
|
const dialog = page.getByRole("dialog", { name: "New space" });
|
||||||
await dialog.getByLabel("Name").fill("Side");
|
await dialog.getByLabel("Name").fill("Side");
|
||||||
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
|
||||||
await page.waitForURL(/\/(onboarding|app)/);
|
await page.waitForURL(/\/app(?:\/)?$/);
|
||||||
await completeOnboarding(page);
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
|
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
// Delete the only bot in the new space: the app must stay put instead of
|
// Delete the only bot in the new space: the app must stay put instead of
|
||||||
// forcing onboarding again.
|
// forcing onboarding again.
|
||||||
const [sideChief] = await rpc<Array<{ id: string }>>(page, "bots/list", {});
|
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
expect(sideChief).toBeTruthy();
|
name: "Side bot",
|
||||||
await rpc(page, "bots/remove", { botId: sideChief!.id, deleteMemories: false });
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
await rpc(page, "bots/remove", { botId: sideBot.id, deleteMemories: false });
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
await page.goto("/app");
|
await page.goto("/app");
|
||||||
await expect(page).not.toHaveURL(/\/onboarding/);
|
await expect(page).not.toHaveURL(/\/onboarding/);
|
||||||
@ -225,5 +218,5 @@ test("deleting the last bot in a space stays in the app after first use", async
|
|||||||
const archivedToggle = sidebar.getByRole("button", { name: /^Archived/ });
|
const archivedToggle = sidebar.getByRole("button", { name: /^Archived/ });
|
||||||
await expect(archivedToggle).toBeVisible({ timeout: 30_000 });
|
await expect(archivedToggle).toBeVisible({ timeout: 30_000 });
|
||||||
await archivedToggle.click();
|
await archivedToggle.click();
|
||||||
await expect(sidebar.getByText("Chief", { exact: true })).toBeVisible();
|
await expect(sidebar.getByText("Helper", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
createNamedBot,
|
createNamedBot,
|
||||||
|
openMainBotThread,
|
||||||
realSandboxTimeout,
|
realSandboxTimeout,
|
||||||
rpc,
|
rpc,
|
||||||
signup,
|
signup,
|
||||||
@ -90,7 +91,8 @@ test("user control leaves another Team bot's screen available", async ({ page },
|
|||||||
const chiefId = activeBotId(page);
|
const chiefId = activeBotId(page);
|
||||||
const workerId = await createBot(page, "Worker", "team");
|
const workerId = await createBot(page, "Worker", "team");
|
||||||
|
|
||||||
await openBot(page, "Chief");
|
// The sidebar entry for the main bot opens the main conversation; its computer is in the 1:1.
|
||||||
|
await openMainBotThread(page);
|
||||||
await page.getByTitle("Agent computer").click();
|
await page.getByTitle("Agent computer").click();
|
||||||
await page.getByTestId("computer-preview").hover();
|
await page.getByTestId("computer-preview").hover();
|
||||||
await page.getByTestId("computer-preview-open").click();
|
await page.getByTestId("computer-preview-open").click();
|
||||||
|
|||||||
@ -68,6 +68,12 @@ describe("group-stage", () => {
|
|||||||
expect(speaker?.botId).toBe("b1");
|
expect(speaker?.botId).toBe("b1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the lead, not the first member, when nobody has spoken", () => {
|
||||||
|
const quiet = { threadId: "t1", cursor: 0, messages: [], olderCursor: null, members };
|
||||||
|
expect(resolveActiveGroupSpeaker({ ...quiet, leadBotId: "b2" } as never, [])?.botId).toBe("b2");
|
||||||
|
expect(resolveActiveGroupSpeaker(quiet as never, [])?.botId).toBe("b1");
|
||||||
|
});
|
||||||
|
|
||||||
it("builds ensemble without foreground speaker", () => {
|
it("builds ensemble without foreground speaker", () => {
|
||||||
const ensemble = resolveGroupEnsemble(
|
const ensemble = resolveGroupEnsemble(
|
||||||
{ threadId: "t1", cursor: 0, messages: [], olderCursor: null, members } as never,
|
{ threadId: "t1", cursor: 0, messages: [], olderCursor: null, members } as never,
|
||||||
|
|||||||
@ -51,7 +51,7 @@ function findMember(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground Oo for a group encounter: streaming bot → latest bot reply → newest active run → ensemble fallback.
|
* Foreground Oo for a group encounter: streaming bot → latest bot reply → newest active run → lead → first member.
|
||||||
*/
|
*/
|
||||||
export function resolveActiveGroupSpeaker(
|
export function resolveActiveGroupSpeaker(
|
||||||
snapshot: ThreadSnapshot | null,
|
snapshot: ThreadSnapshot | null,
|
||||||
@ -86,6 +86,8 @@ export function resolveActiveGroupSpeaker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (workingBots[0]) return workingBots[0];
|
if (workingBots[0]) return workingBots[0];
|
||||||
|
const lead = snapshot?.leadBotId ? findMember(members, snapshot.leadBotId, enrich) : null;
|
||||||
|
if (lead) return lead;
|
||||||
if (members[0]) return { ...members[0], ...enrich?.(members[0].botId) };
|
if (members[0]) return { ...members[0], ...enrich?.(members[0].botId) };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js";
|
||||||
isEmptyProjectSpace,
|
|
||||||
isHauptthreadGroup,
|
|
||||||
projectGroupChatsInSpace,
|
|
||||||
} from "./project-empty-state.js";
|
|
||||||
|
|
||||||
describe("isHauptthreadGroup", () => {
|
|
||||||
it("recognizes Hauptthread and Team Chat", () => {
|
|
||||||
expect(isHauptthreadGroup({ id: "1", name: "Hauptthread" })).toBe(true);
|
|
||||||
expect(isHauptthreadGroup({ id: "2", name: "Team Chat" })).toBe(true);
|
|
||||||
expect(isHauptthreadGroup({ id: "3", name: "Launch" })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("projectGroupChatsInSpace", () => {
|
describe("projectGroupChatsInSpace", () => {
|
||||||
it("returns project groups in a space without Hauptthread", () => {
|
it("returns project groups in a space without the main conversation", () => {
|
||||||
const groups = [
|
const groups = [
|
||||||
{ id: "g1", name: "Launch", spaceId: "space-a" },
|
{ id: "g1", kind: "group" as const, spaceId: "space-a" },
|
||||||
{ id: "g2", name: "Hauptthread", spaceId: "space-a" },
|
{ id: "g2", kind: "main" as const, spaceId: "space-a" },
|
||||||
{ id: "g3", name: "Ops", spaceId: "space-b" },
|
{ id: "g3", kind: "group" as const, spaceId: "space-b" },
|
||||||
];
|
];
|
||||||
expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]);
|
expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]);
|
||||||
});
|
});
|
||||||
@ -39,12 +27,12 @@ describe("isEmptyProjectSpace", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("is false when the project space already has a group chat", () => {
|
it("is false when the project space already has a group chat", () => {
|
||||||
const groups = [{ id: "g1", name: "Launch", spaceId: "project" }];
|
const groups = [{ id: "g1", kind: "group" as const, spaceId: "project" }];
|
||||||
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
|
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores Hauptthread when deciding emptiness", () => {
|
it("ignores the main conversation when deciding emptiness", () => {
|
||||||
const groups = [{ id: "g1", name: "Hauptthread", spaceId: "project" }];
|
const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }];
|
||||||
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true);
|
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import type { Group, Space } from "@rakazo/contracts";
|
import type { Group, Space } from "@rakazo/contracts";
|
||||||
|
|
||||||
export function isHauptthreadGroup(group: Pick<Group, "id" | "name">): boolean {
|
/** Project-scoped group chats in a space (excludes the main conversation). */
|
||||||
return group.name === "Hauptthread" || group.name === "Team Chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project-scoped group chats in a space (excludes Hauptthread). */
|
|
||||||
export function projectGroupChatsInSpace(
|
export function projectGroupChatsInSpace(
|
||||||
groups: readonly Pick<Group, "id" | "name" | "spaceId">[],
|
groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
): readonly Pick<Group, "id">[] {
|
): readonly Pick<Group, "id">[] {
|
||||||
return groups.filter((group) => group.spaceId === spaceId && !isHauptthreadGroup(group));
|
return groups.filter((group) => group.spaceId === spaceId && group.kind !== "main");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Non-default project space with no project group conversations yet. */
|
/** Non-default project space with no project group conversations yet. */
|
||||||
export function isEmptyProjectSpace(
|
export function isEmptyProjectSpace(
|
||||||
spaceId: string | null | undefined,
|
spaceId: string | null | undefined,
|
||||||
spaces: readonly Pick<Space, "id" | "isDefault">[],
|
spaces: readonly Pick<Space, "id" | "isDefault">[],
|
||||||
groups: readonly Pick<Group, "id" | "name" | "spaceId">[],
|
groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
|
||||||
homeSpaceId: string | undefined,
|
homeSpaceId: string | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false;
|
if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false;
|
||||||
|
|||||||
@ -164,6 +164,8 @@ export function GroupSettings({
|
|||||||
const [selected, setSelected] = useState(group.members.map((member) => member.botId));
|
const [selected, setSelected] = useState(group.members.map((member) => member.botId));
|
||||||
const [pending, setPending] = useState<"save" | "remove" | null>(null);
|
const [pending, setPending] = useState<"save" | "remove" | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Bots join the main conversation when addressed, and it cannot be deleted.
|
||||||
|
const main = group.kind === "main";
|
||||||
|
|
||||||
async function mutate(kind: "save" | "remove", action: () => Promise<void>) {
|
async function mutate(kind: "save" | "remove", action: () => Promise<void>) {
|
||||||
if (pending) return;
|
if (pending) return;
|
||||||
@ -217,32 +219,38 @@ export function GroupSettings({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-5 text-sm text-muted-foreground">
|
{main ? null : (
|
||||||
<Trans>
|
<>
|
||||||
Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX})
|
<div className="mt-5 text-sm text-muted-foreground">
|
||||||
</Trans>
|
<Trans>
|
||||||
</div>
|
Members ({GROUP_MEMBER_MIN}–{GROUP_MEMBER_MAX})
|
||||||
<MemberPicker
|
</Trans>
|
||||||
bots={bots}
|
</div>
|
||||||
selected={selected}
|
<MemberPicker
|
||||||
onChange={setSelected}
|
bots={bots}
|
||||||
maxHeight="max-h-[240px]"
|
selected={selected}
|
||||||
/>
|
onChange={setSelected}
|
||||||
|
maxHeight="max-h-[240px]"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="mt-5 w-full"
|
className="mt-5 w-full"
|
||||||
disabled={pending !== null || !validSelection(name, selected)}
|
disabled={pending !== null || !(main ? name.trim() : validSelection(name, selected))}
|
||||||
onClick={() => void mutate("save", save)}
|
onClick={() => void mutate("save", save)}
|
||||||
>
|
>
|
||||||
{pending === "save" ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
{pending === "save" ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{main ? null : (
|
||||||
variant="destructive"
|
<Button
|
||||||
className="mt-4 w-full"
|
variant="destructive"
|
||||||
disabled={pending !== null}
|
className="mt-4 w-full"
|
||||||
onClick={() => void mutate("remove", onRemove)}
|
disabled={pending !== null}
|
||||||
>
|
onClick={() => void mutate("remove", onRemove)}
|
||||||
{pending === "remove" ? <Trans>Deleting…</Trans> : <Trans>Delete group</Trans>}
|
>
|
||||||
</Button>
|
{pending === "remove" ? <Trans>Deleting…</Trans> : <Trans>Delete group</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { IntegrationSetupState } from "@rakazo/contracts";
|
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
||||||
import { Button, Input } from "@rakazo/ui-web";
|
import { Button, Input } from "@rakazo/ui-web";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -22,61 +22,15 @@ import {
|
|||||||
} from "../lib/onboarding-discovery";
|
} from "../lib/onboarding-discovery";
|
||||||
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
||||||
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc, selectSpace } from "../lib/rpc";
|
||||||
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
||||||
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
||||||
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
||||||
|
|
||||||
const FIRST_BOT_NAME = "Chief";
|
/** Selects the main conversation's space and returns its route. */
|
||||||
const FIRST_BOT_SPAWN_KEY = "onboarding:first";
|
function enterMainConversation(main: MainConversationRef): string {
|
||||||
const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot";
|
selectSpace(main.spaceId);
|
||||||
|
return `/app/g/${main.groupId}`;
|
||||||
let firstBotEnsure: Promise<{ id: string }> | null = null;
|
|
||||||
|
|
||||||
function findFirstBot(
|
|
||||||
bots: Array<{ id: string; name: string; spawnKey: string | null }>,
|
|
||||||
): { id: string } | undefined {
|
|
||||||
const bySpawnKey = bots.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY);
|
|
||||||
if (bySpawnKey) return { id: bySpawnKey.id };
|
|
||||||
const byName = bots.find((bot) => bot.name === FIRST_BOT_NAME);
|
|
||||||
return byName ? { id: byName.id } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOrReuseFirstBot(avatarColor?: string): Promise<{ id: string }> {
|
|
||||||
const existing = await rpc.bots.list();
|
|
||||||
const reuse = findFirstBot(existing);
|
|
||||||
if (reuse) return reuse;
|
|
||||||
try {
|
|
||||||
const created = await rpc.bots.create({
|
|
||||||
name: FIRST_BOT_NAME,
|
|
||||||
title: "Chief of Staff",
|
|
||||||
description: "Coordinates specialists — routes work, never a do-everything generalist.",
|
|
||||||
instructions: "",
|
|
||||||
notifyOnFinish: true,
|
|
||||||
spawnKey: FIRST_BOT_SPAWN_KEY,
|
|
||||||
color: avatarColor,
|
|
||||||
});
|
|
||||||
return { id: created.id };
|
|
||||||
} catch (error) {
|
|
||||||
const afterConflict = await rpc.bots.list();
|
|
||||||
const winner = afterConflict.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY);
|
|
||||||
if (winner) return { id: winner.id };
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withFirstBotLock<T>(run: () => Promise<T>): Promise<T> {
|
|
||||||
const locks = globalThis.navigator?.locks;
|
|
||||||
if (!locks?.request) return run();
|
|
||||||
return locks.request(FIRST_BOT_LOCK, run);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> {
|
|
||||||
if (firstBotEnsure) return firstBotEnsure;
|
|
||||||
firstBotEnsure = withFirstBotLock(() => createOrReuseFirstBot(avatarColor)).finally(() => {
|
|
||||||
firstBotEnsure = null;
|
|
||||||
});
|
|
||||||
return firstBotEnsure;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultEmojiConfig(): OnboardingEmojiConfig {
|
function defaultEmojiConfig(): OnboardingEmojiConfig {
|
||||||
@ -190,23 +144,26 @@ export function OnboardingPage() {
|
|||||||
// Get bot ID for scan phase
|
// Get bot ID for scan phase
|
||||||
try {
|
try {
|
||||||
const botList = await rpc.bots.list();
|
const botList = await rpc.bots.list();
|
||||||
const firstBot = botList.find(
|
const mainBot = botList.find((b) => b.kind === "main");
|
||||||
(b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME,
|
if (mainBot && !cancelled) setScanBotId(mainBot.id);
|
||||||
);
|
|
||||||
if (firstBot && !cancelled) setScanBotId(firstBot.id);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
}
|
}
|
||||||
setIntegrationSetup(integrations);
|
setIntegrationSetup(integrations);
|
||||||
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
// The first step renders before this resolves; never pull a user back who moved on.
|
||||||
// A space that already has a bot does not need the designer again.
|
if (!me.needsModel && integrations?.needsSetup) {
|
||||||
// Empty spaces (a new project) still walk onboarding. Resolve this after
|
setStep((current) => (current === "emoji" ? "integrations" : current));
|
||||||
// the first step renders so a slow spaces list cannot leave E2E on loading.
|
}
|
||||||
|
// Onboarding runs once: a user with a main bot, or a space that already
|
||||||
|
// has a bot, does not need the designer again. Resolve this after the
|
||||||
|
// first step renders so a slow spaces list cannot leave E2E on loading.
|
||||||
try {
|
try {
|
||||||
const navigation = await rpc.spaces.list();
|
const navigation = await rpc.spaces.list();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const existing = navigation.current.bots[0];
|
const existing = navigation.current.bots[0];
|
||||||
if (existing && !me.needsModel) {
|
if (navigation.main && !me.needsModel) {
|
||||||
|
navigate(enterMainConversation(navigation.main), { replace: true });
|
||||||
|
} else if (existing && !me.needsModel) {
|
||||||
navigate(`/app/${existing.id}`, { replace: true });
|
navigate(`/app/${existing.id}`, { replace: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -214,7 +171,7 @@ export function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setStep("emoji");
|
if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -265,7 +222,8 @@ export function OnboardingPage() {
|
|||||||
createStartedRef.current = true;
|
createStartedRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const bot = await ensureFirstBot(emojiConfig.avatarValue);
|
const bot = await rpc.bots.ensureMain();
|
||||||
|
await rpc.bots.update({ botId: bot.id, color: emojiConfig.avatarValue }).catch(() => null);
|
||||||
setCrewReady(true);
|
setCrewReady(true);
|
||||||
|
|
||||||
await rpc.profile
|
await rpc.profile
|
||||||
@ -310,7 +268,11 @@ export function OnboardingPage() {
|
|||||||
if (started) {
|
if (started) {
|
||||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
navigate(`/app/${bot.id}`);
|
const main = await rpc.spaces
|
||||||
|
.list()
|
||||||
|
.then((navigation) => navigation.main)
|
||||||
|
.catch(() => null);
|
||||||
|
navigate(main ? enterMainConversation(main) : `/app/${bot.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
createStartedRef.current = false;
|
createStartedRef.current = false;
|
||||||
setError(err instanceof Error ? err.message : t`Could not create your bot`);
|
setError(err instanceof Error ? err.message : t`Could not create your bot`);
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import type {
|
|||||||
Connection,
|
Connection,
|
||||||
ConnectionCatalogItem,
|
ConnectionCatalogItem,
|
||||||
Group,
|
Group,
|
||||||
|
MainConversationRef,
|
||||||
Me,
|
Me,
|
||||||
ProductEvent,
|
ProductEvent,
|
||||||
Routine,
|
Routine,
|
||||||
@ -275,6 +276,7 @@ import {
|
|||||||
PickerInfoDialog,
|
PickerInfoDialog,
|
||||||
RenameBotSectionDialog,
|
RenameBotSectionDialog,
|
||||||
} from "./shell/dialogs";
|
} from "./shell/dialogs";
|
||||||
|
import { MainConversationRow } from "./shell/main-conversation-row";
|
||||||
import {
|
import {
|
||||||
AppConnectCard,
|
AppConnectCard,
|
||||||
ArtifactImage,
|
ArtifactImage,
|
||||||
@ -282,6 +284,13 @@ import {
|
|||||||
ChoiceCard,
|
ChoiceCard,
|
||||||
McpApprovalCard,
|
McpApprovalCard,
|
||||||
} from "./shell/message-cards";
|
} from "./shell/message-cards";
|
||||||
|
import {
|
||||||
|
rosterNameClass,
|
||||||
|
rosterPreviewClass,
|
||||||
|
rosterRowClass,
|
||||||
|
rosterTimeClass,
|
||||||
|
rosterUnreadDotClass,
|
||||||
|
} from "./shell/roster-row-style";
|
||||||
import { WindowChrome } from "./WindowChrome";
|
import { WindowChrome } from "./WindowChrome";
|
||||||
|
|
||||||
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
||||||
@ -445,6 +454,7 @@ export function ShellPage() {
|
|||||||
const savingBotOrderRef = useRef(false);
|
const savingBotOrderRef = useRef(false);
|
||||||
const [botSections, setBotSections] = useState<BotSection[]>([]);
|
const [botSections, setBotSections] = useState<BotSection[]>([]);
|
||||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||||
|
const [mainConversation, setMainConversation] = useState<MainConversationRef | null>(null);
|
||||||
const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
|
const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
|
||||||
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
|
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
|
||||||
const {
|
const {
|
||||||
@ -1035,6 +1045,7 @@ export function ShellPage() {
|
|||||||
setBotSections(sections);
|
setBotSections(sections);
|
||||||
setGroups(groupList);
|
setGroups(groupList);
|
||||||
setSpaces(navigation.spaces);
|
setSpaces(navigation.spaces);
|
||||||
|
setMainConversation(navigation.main);
|
||||||
setInitialBotsLoaded(true);
|
setInitialBotsLoaded(true);
|
||||||
botsRefreshApplied.current = request;
|
botsRefreshApplied.current = request;
|
||||||
if (
|
if (
|
||||||
@ -1274,6 +1285,7 @@ export function ShellPage() {
|
|||||||
setArchivedGroups(bootstrap.archivedGroups);
|
setArchivedGroups(bootstrap.archivedGroups);
|
||||||
setGroups(groupList);
|
setGroups(groupList);
|
||||||
setSpaces(bootstrap.spaces);
|
setSpaces(bootstrap.spaces);
|
||||||
|
setMainConversation(bootstrap.main);
|
||||||
setInitialBotsLoaded(true);
|
setInitialBotsLoaded(true);
|
||||||
}
|
}
|
||||||
if (!groupId && bootstrap.thread) {
|
if (!groupId && bootstrap.thread) {
|
||||||
@ -1605,6 +1617,10 @@ export function ShellPage() {
|
|||||||
if (event.type === "group.created") {
|
if (event.type === "group.created") {
|
||||||
void refreshBots().catch(() => undefined);
|
void refreshBots().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
if (event.type === "group.updated") {
|
||||||
|
void refreshGroupThread(groupId).catch(() => undefined);
|
||||||
|
void refreshBots().catch(() => undefined);
|
||||||
|
}
|
||||||
if (event.type === "run.started" || isRunTerminalEvent(event)) {
|
if (event.type === "run.started" || isRunTerminalEvent(event)) {
|
||||||
void refreshBots().catch(() => undefined);
|
void refreshBots().catch(() => undefined);
|
||||||
}
|
}
|
||||||
@ -1701,11 +1717,6 @@ export function ShellPage() {
|
|||||||
});
|
});
|
||||||
}, [bootstrapMe, botSections, bots, groups, spaces, query]);
|
}, [bootstrapMe, botSections, bots, groups, spaces, query]);
|
||||||
|
|
||||||
const hauptthreadGroup = useMemo(
|
|
||||||
() => groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat"),
|
|
||||||
[groups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const homeSpaceBots = useMemo(() => {
|
const homeSpaceBots = useMemo(() => {
|
||||||
if (!homeSpaceId) return bots;
|
if (!homeSpaceId) return bots;
|
||||||
const home = spaces.find((space) => space.id === homeSpaceId);
|
const home = spaces.find((space) => space.id === homeSpaceId);
|
||||||
@ -1720,11 +1731,27 @@ export function ShellPage() {
|
|||||||
...group,
|
...group,
|
||||||
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
title: stripSpaceTitlePrefix(group.title, group.spaceName),
|
||||||
showLock: false,
|
showLock: false,
|
||||||
bots: group.bots.filter((item) => item.kind === "bot"),
|
bots: group.bots
|
||||||
|
.filter((item) => item.kind === "bot")
|
||||||
|
.filter((item) => item.chat.kind !== "main"),
|
||||||
}))
|
}))
|
||||||
.filter((group) => group.bots.length > 0);
|
.filter((group) => group.bots.length > 0);
|
||||||
}, [homeSpaceId, sidebarGroups]);
|
}, [homeSpaceId, sidebarGroups]);
|
||||||
|
|
||||||
|
const mainConversationEntry = useMemo(() => {
|
||||||
|
if (!mainConversation) return null;
|
||||||
|
const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId;
|
||||||
|
const space = spaces.find((item) => item.id === mainConversation.spaceId);
|
||||||
|
const bot = (inCurrentSpace ? bots : space?.bots)?.find(
|
||||||
|
(item) => item.id === mainConversation.botId,
|
||||||
|
);
|
||||||
|
const group = (inCurrentSpace ? groups : space?.groups)?.find(
|
||||||
|
(item) => item.id === mainConversation.groupId,
|
||||||
|
);
|
||||||
|
if (!bot || !group) return null;
|
||||||
|
return { ...mainConversation, bot, group };
|
||||||
|
}, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]);
|
||||||
|
|
||||||
const projectSidebarGroups = useMemo(() => {
|
const projectSidebarGroups = useMemo(() => {
|
||||||
if (!homeSpaceId) return [];
|
if (!homeSpaceId) return [];
|
||||||
const entries: typeof sidebarGroups = [];
|
const entries: typeof sidebarGroups = [];
|
||||||
@ -1746,9 +1773,7 @@ export function ShellPage() {
|
|||||||
seenProjectSpaceIds.add(space.id);
|
seenProjectSpaceIds.add(space.id);
|
||||||
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
||||||
group.spaceId === space.id
|
group.spaceId === space.id
|
||||||
? group.bots.filter(
|
? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main")
|
||||||
(item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id,
|
|
||||||
)
|
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const projectBots = dedupeSidebarBots(groupsInSpace);
|
const projectBots = dedupeSidebarBots(groupsInSpace);
|
||||||
@ -1769,7 +1794,7 @@ export function ShellPage() {
|
|||||||
for (const group of sidebarGroups) {
|
for (const group of sidebarGroups) {
|
||||||
if (group.spaceId !== homeSpaceId) continue;
|
if (group.spaceId !== homeSpaceId) continue;
|
||||||
const groupChats = dedupeSidebarBots(
|
const groupChats = dedupeSidebarBots(
|
||||||
group.bots.filter((item) => item.kind === "group" && item.chat.id !== hauptthreadGroup?.id),
|
group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"),
|
||||||
).filter((item) => {
|
).filter((item) => {
|
||||||
if (seenProjectGroupChatIds.has(item.chat.id)) return false;
|
if (seenProjectGroupChatIds.has(item.chat.id)) return false;
|
||||||
seenProjectGroupChatIds.add(item.chat.id);
|
seenProjectGroupChatIds.add(item.chat.id);
|
||||||
@ -1800,7 +1825,7 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}, [hauptthreadGroup?.id, homeSpaceId, sidebarGroups, spaces, projectOrder]);
|
}, [homeSpaceId, sidebarGroups, spaces, projectOrder]);
|
||||||
|
|
||||||
// DnD handlers
|
// DnD handlers
|
||||||
const handleProjectDragEnd = useCallback(
|
const handleProjectDragEnd = useCallback(
|
||||||
@ -1852,10 +1877,20 @@ export function ShellPage() {
|
|||||||
[openSpaceChat],
|
[openSpaceChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openMainConversation = useCallback(() => {
|
||||||
|
if (!mainConversation) return;
|
||||||
|
setActiveNavPillar("team");
|
||||||
|
openSpaceChat(mainConversation.spaceId, `/app/g/${mainConversation.groupId}`);
|
||||||
|
}, [mainConversation, openSpaceChat]);
|
||||||
|
|
||||||
const openConversationBot = useCallback(
|
const openConversationBot = useCallback(
|
||||||
(botId: string) => {
|
(botId: string) => {
|
||||||
setConversationPickerOpen(false);
|
setConversationPickerOpen(false);
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
|
if (botId === mainConversation?.botId) {
|
||||||
|
openMainConversation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActiveNavPillar("team");
|
setActiveNavPillar("team");
|
||||||
const bot =
|
const bot =
|
||||||
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
|
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
|
||||||
@ -1865,7 +1900,7 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
navigate(`/app/${botId}`);
|
navigate(`/app/${botId}`);
|
||||||
},
|
},
|
||||||
[bots, homeSpaceBots, navigate, openSpaceChat],
|
[bots, homeSpaceBots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const flushBotOrder = useCallback(async () => {
|
const flushBotOrder = useCallback(async () => {
|
||||||
@ -2152,6 +2187,13 @@ export function ShellPage() {
|
|||||||
[active?.id, groupId, inGroup],
|
[active?.id, groupId, inGroup],
|
||||||
);
|
);
|
||||||
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
||||||
|
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
||||||
|
const knownBots = useMemo(() => {
|
||||||
|
const byId = new Map<string, { name: string; color: string; status?: string }>();
|
||||||
|
for (const space of spaces) for (const bot of space.bots) byId.set(bot.id, bot);
|
||||||
|
for (const bot of bots) byId.set(bot.id, bot);
|
||||||
|
return byId;
|
||||||
|
}, [bots, spaces]);
|
||||||
const resolveTranscriptBot = useCallback(
|
const resolveTranscriptBot = useCallback(
|
||||||
(botId: string) => {
|
(botId: string) => {
|
||||||
const bot = bots.find((candidate) => candidate.id === botId);
|
const bot = bots.find((candidate) => candidate.id === botId);
|
||||||
@ -2160,6 +2202,10 @@ export function ShellPage() {
|
|||||||
},
|
},
|
||||||
[bots, transcriptMembers],
|
[bots, transcriptMembers],
|
||||||
);
|
);
|
||||||
|
const resolveSpeakerBot = useCallback(
|
||||||
|
(botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId),
|
||||||
|
[knownBots, resolveTranscriptBot],
|
||||||
|
);
|
||||||
const enrichStageMember = useCallback(
|
const enrichStageMember = useCallback(
|
||||||
(botId: string) => {
|
(botId: string) => {
|
||||||
const bot = resolveTranscriptBot(botId);
|
const bot = resolveTranscriptBot(botId);
|
||||||
@ -2354,8 +2400,10 @@ export function ShellPage() {
|
|||||||
}, [activeSnapshot?.messages, inGroup]);
|
}, [activeSnapshot?.messages, inGroup]);
|
||||||
|
|
||||||
const resolveTranscriptMemberName = useCallback(
|
const resolveTranscriptMemberName = useCallback(
|
||||||
(botId: string | undefined) => memberName(transcriptMembers, botId),
|
(botId: string | undefined) =>
|
||||||
[transcriptMembers],
|
memberName(transcriptMembers, botId) ??
|
||||||
|
(inGroup && botId ? knownBots.get(botId)?.name : undefined),
|
||||||
|
[inGroup, knownBots, transcriptMembers],
|
||||||
);
|
);
|
||||||
const replyTargetName = activeReplyTarget
|
const replyTargetName = activeReplyTarget
|
||||||
? activeReplyTarget.role === "user"
|
? activeReplyTarget.role === "user"
|
||||||
@ -3294,23 +3342,6 @@ export function ShellPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active && !activeGroup && initialBotsLoaded && !emptyProjectSpace) {
|
|
||||||
const hauptthread = groups.find((g) => g.name === "Hauptthread" || g.name === "Team Chat");
|
|
||||||
if (hauptthread) {
|
|
||||||
navigate(`/app/g/${hauptthread.id}`);
|
|
||||||
} else if (bots.length > 0) {
|
|
||||||
rpc.groups
|
|
||||||
.create({ name: "Hauptthread", botIds: bots.map((b) => b.id) })
|
|
||||||
.then((group) => {
|
|
||||||
setGroups((curr) => [group, ...curr]);
|
|
||||||
navigate(`/app/g/${group.id}`);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [active, activeGroup, emptyProjectSpace, initialBotsLoaded, groups, bots, navigate]);
|
|
||||||
|
|
||||||
const userName = session.data?.user.name ?? t`You`;
|
const userName = session.data?.user.name ?? t`You`;
|
||||||
const initials = userName
|
const initials = userName
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@ -3587,7 +3618,25 @@ export function ShellPage() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{conversationSidebarGroups.length === 0 ? (
|
{mainConversationEntry ? (
|
||||||
|
<div data-sidebar-group="main">
|
||||||
|
<MainConversationRow
|
||||||
|
name={mainConversationEntry.group.name}
|
||||||
|
botId={mainConversationEntry.bot.id}
|
||||||
|
color={mainConversationEntry.bot.color}
|
||||||
|
status={mainConversationEntry.bot.status}
|
||||||
|
unread={mainConversationEntry.group.unread}
|
||||||
|
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
|
||||||
|
subtitle={rosterActivitySubtitle(
|
||||||
|
mainConversationEntry.bot.status,
|
||||||
|
cleanRosterPreview(mainConversationEntry.group.preview),
|
||||||
|
)}
|
||||||
|
active={inGroup && groupId === mainConversationEntry.groupId}
|
||||||
|
onOpen={openMainConversation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
|
||||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||||
<p>{t`Noch keine Unterhaltung.`}</p>
|
<p>{t`Noch keine Unterhaltung.`}</p>
|
||||||
<Button
|
<Button
|
||||||
@ -3761,11 +3810,9 @@ export function ShellPage() {
|
|||||||
position: { x: event.clientX, y: event.clientY },
|
position: { x: event.clientX, y: event.clientY },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] cursor-grab active:cursor-grabbing ${
|
className={`${rosterRowClass(
|
||||||
!inGroup && active?.id === item.chat.id
|
!inGroup && active?.id === item.chat.id,
|
||||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
)} cursor-grab active:cursor-grabbing`}
|
||||||
: "hover:bg-white/[0.05] border border-transparent"
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
|
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
|
||||||
}}
|
}}
|
||||||
@ -3793,11 +3840,9 @@ export function ShellPage() {
|
|||||||
<span
|
<span
|
||||||
dir="auto"
|
dir="auto"
|
||||||
data-roster-bot-name=""
|
data-roster-bot-name=""
|
||||||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
|
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
|
||||||
item.chat.unread
|
item.chat.unread,
|
||||||
? "font-bold text-white"
|
)}`}
|
||||||
: "font-semibold text-white/95"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.chat.name}
|
{item.chat.name}
|
||||||
</span>
|
</span>
|
||||||
@ -3817,22 +3862,19 @@ export function ShellPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
<span className={rosterTimeClass}>
|
||||||
{formatRosterTime(item.chat.updatedAt)}
|
{formatRosterTime(item.chat.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{item.chat.unread ? (
|
{item.chat.unread ? (
|
||||||
<span
|
<span aria-hidden="true" className={rosterUnreadDotClass} />
|
||||||
aria-hidden="true"
|
|
||||||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
|
||||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
item.chat.unread,
|
||||||
}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{rosterActivitySubtitle(
|
{rosterActivitySubtitle(
|
||||||
item.chat.status,
|
item.chat.status,
|
||||||
@ -4455,7 +4497,7 @@ export function ShellPage() {
|
|||||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||||
{t`Gruppenunterhaltung`}
|
{t`Gruppenunterhaltung`}
|
||||||
</span>
|
</span>
|
||||||
) : inGroup ? (
|
) : inGroup && activeGroup?.kind !== "main" ? (
|
||||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||||
{t`Projekt · Sessions & Ordner`}
|
{t`Projekt · Sessions & Ordner`}
|
||||||
</span>
|
</span>
|
||||||
@ -4584,7 +4626,7 @@ export function ShellPage() {
|
|||||||
setPeerConversation(peer);
|
setPeerConversation(peer);
|
||||||
}}
|
}}
|
||||||
memberName={resolveTranscriptMemberName}
|
memberName={resolveTranscriptMemberName}
|
||||||
peerBot={resolveTranscriptBot}
|
peerBot={resolveSpeakerBot}
|
||||||
onRefresh={refreshActiveThread}
|
onRefresh={refreshActiveThread}
|
||||||
onBotChanged={refreshBots}
|
onBotChanged={refreshBots}
|
||||||
onAddRoutine={addSkillRoutine}
|
onAddRoutine={addSkillRoutine}
|
||||||
@ -5465,7 +5507,7 @@ export function ShellPage() {
|
|||||||
await refreshBots();
|
await refreshBots();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding");
|
window.location.assign("/app");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
65
apps/web/src/pages/shell/main-conversation-row.tsx
Normal file
65
apps/web/src/pages/shell/main-conversation-row.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { BotAvatar } from "@rakazo/ui-web";
|
||||||
|
import {
|
||||||
|
rosterNameClass,
|
||||||
|
rosterPreviewClass,
|
||||||
|
rosterRowClass,
|
||||||
|
rosterTimeClass,
|
||||||
|
rosterUnreadDotClass,
|
||||||
|
} from "./roster-row-style";
|
||||||
|
|
||||||
|
/** First sidebar entry in every space: the main conversation, shown as its main bot. */
|
||||||
|
export function MainConversationRow({
|
||||||
|
name,
|
||||||
|
botId,
|
||||||
|
color,
|
||||||
|
status,
|
||||||
|
unread,
|
||||||
|
time,
|
||||||
|
subtitle,
|
||||||
|
active,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
botId: string;
|
||||||
|
color: string;
|
||||||
|
status?: string;
|
||||||
|
unread: boolean;
|
||||||
|
time: string;
|
||||||
|
subtitle: string;
|
||||||
|
active: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-main-conversation=""
|
||||||
|
aria-label={t`${name}, main conversation`}
|
||||||
|
onClick={onOpen}
|
||||||
|
className={rosterRowClass(active)}
|
||||||
|
>
|
||||||
|
<BotAvatar color={color} identity={botId} size={40} status={status} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<span
|
||||||
|
dir="auto"
|
||||||
|
className={`truncate text-ui-body tracking-tight ${rosterNameClass(unread)}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<span className={rosterTimeClass}>{time}</span>
|
||||||
|
{unread ? <span aria-hidden="true" className={rosterUnreadDotClass} /> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
dir="auto"
|
||||||
|
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(unread)}`}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/pages/shell/roster-row-style.ts
Normal file
20
apps/web/src/pages/shell/roster-row-style.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
|
||||||
|
export function rosterRowClass(selected: boolean): string {
|
||||||
|
return `flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||||
|
selected
|
||||||
|
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||||
|
: "hover:bg-white/[0.05] border border-transparent"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rosterNameClass(unread: boolean): string {
|
||||||
|
return unread ? "font-bold text-white" : "font-semibold text-white/95";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rosterTimeClass = "text-ui-body-sm text-zinc-400 tabular-nums font-medium";
|
||||||
|
|
||||||
|
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-emerald-400";
|
||||||
|
|
||||||
|
export function rosterPreviewClass(unread: boolean): string {
|
||||||
|
return unread ? "font-medium text-white" : "text-zinc-300";
|
||||||
|
}
|
||||||
@ -243,6 +243,45 @@ describe("spawned bot archival", () => {
|
|||||||
),
|
),
|
||||||
).resolves.toMatchObject({ ok: true, botId: "child-1", name: "Scout" });
|
).resolves.toMatchObject({ ok: true, botId: "child-1", name: "Scout" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses to archive the main bot", async () => {
|
||||||
|
const transaction = vi.fn();
|
||||||
|
const prisma = {
|
||||||
|
bot: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "main-1",
|
||||||
|
spaceId: "workspace-1",
|
||||||
|
userId: "user-1",
|
||||||
|
parentBotId: "parent-1",
|
||||||
|
name: "Chief",
|
||||||
|
kind: "main",
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
$transaction: transaction,
|
||||||
|
} as unknown as PrismaClient;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
archiveSpawnedBot(
|
||||||
|
{
|
||||||
|
prisma,
|
||||||
|
sandbox: {} as SandboxProvider,
|
||||||
|
home: {} as AgentHomeStore,
|
||||||
|
jobs: { cancel: vi.fn() } as unknown as JobPublisher,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
spawnedByBotId: "parent-1",
|
||||||
|
userId: "user-1",
|
||||||
|
spaceId: "workspace-1",
|
||||||
|
confirmName: "Chief",
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ error: "The main bot can't be archived." });
|
||||||
|
expect(transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("destroyBot", () => {
|
describe("destroyBot", () => {
|
||||||
@ -334,7 +373,7 @@ describe("destroyBot", () => {
|
|||||||
expect(removeArtifact).toHaveBeenCalledWith("stored-artifact", context);
|
expect(removeArtifact).toHaveBeenCalledWith("stored-artifact", context);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dissolves groups with fewer than two active members after deleting the bot", async () => {
|
it("dissolves groups with fewer than two active members, never the main conversation", async () => {
|
||||||
const deleteGroups = vi.fn().mockResolvedValue({ count: 1 });
|
const deleteGroups = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const deleteMemberships = vi.fn().mockResolvedValue({ count: 1 });
|
const deleteMemberships = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||||
@ -387,6 +426,15 @@ describe("destroyBot", () => {
|
|||||||
{ botId: "bot-3", bot: { archivedAt: null } },
|
{ botId: "bot-3", bot: { archivedAt: null } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "main-conversation",
|
||||||
|
kind: "main",
|
||||||
|
thread: { id: "thread-main" },
|
||||||
|
members: [
|
||||||
|
{ botId: "bot-1", bot: { archivedAt: null } },
|
||||||
|
{ botId: "main-bot", bot: { archivedAt: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
deleteMany: deleteGroups,
|
deleteMany: deleteGroups,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -255,6 +255,9 @@ export async function archiveSpawnedBot(
|
|||||||
if (target.id === input.spawnedByBotId) {
|
if (target.id === input.spawnedByBotId) {
|
||||||
return { error: "A bot cannot archive itself with archive_bot." };
|
return { error: "A bot cannot archive itself with archive_bot." };
|
||||||
}
|
}
|
||||||
|
if (target.kind === "main") {
|
||||||
|
return { error: "The main bot can't be archived." };
|
||||||
|
}
|
||||||
|
|
||||||
await archiveBot(deps, target, context);
|
await archiveBot(deps, target, context);
|
||||||
return { ok: true as const, botId: target.id, name: target.name };
|
return { ok: true as const, botId: target.id, name: target.name };
|
||||||
@ -481,6 +484,8 @@ async function detachBotFromGroups(tx: Prisma.TransactionClient, botId: string)
|
|||||||
});
|
});
|
||||||
const dissolvedGroupIds: string[] = [];
|
const dissolvedGroupIds: string[] = [];
|
||||||
for (const group of affectedGroups) {
|
for (const group of affectedGroups) {
|
||||||
|
// The main conversation outlives every member; its lead is restored on next use.
|
||||||
|
if (group.kind === "main") continue;
|
||||||
const activeMembersAfterDeletion = group.members.filter(
|
const activeMembersAfterDeletion = group.members.filter(
|
||||||
(member) => member.botId !== botId && member.bot.archivedAt === null,
|
(member) => member.botId !== botId && member.bot.archivedAt === null,
|
||||||
).length;
|
).length;
|
||||||
|
|||||||
@ -303,6 +303,14 @@ export const SpaceSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Space = z.infer<typeof SpaceSchema>;
|
export type Space = z.infer<typeof SpaceSchema>;
|
||||||
|
|
||||||
|
/** Where the user's main bot and main conversation live; the space may differ from the current one. */
|
||||||
|
export const MainConversationRefSchema = z.object({
|
||||||
|
botId: Id,
|
||||||
|
groupId: Id,
|
||||||
|
spaceId: Id,
|
||||||
|
});
|
||||||
|
export type MainConversationRef = z.infer<typeof MainConversationRefSchema>;
|
||||||
|
|
||||||
export const SpaceNavigationSchema = z.object({
|
export const SpaceNavigationSchema = z.object({
|
||||||
current: z.object({
|
current: z.object({
|
||||||
id: Id,
|
id: Id,
|
||||||
@ -313,6 +321,8 @@ export const SpaceNavigationSchema = z.object({
|
|||||||
botSections: z.array(BotSectionSchema),
|
botSections: z.array(BotSectionSchema),
|
||||||
}),
|
}),
|
||||||
spaces: z.array(SpaceSchema),
|
spaces: z.array(SpaceSchema),
|
||||||
|
/** Null until onboarding has created the main bot. */
|
||||||
|
main: MainConversationRefSchema.nullable(),
|
||||||
});
|
});
|
||||||
export type SpaceNavigation = z.infer<typeof SpaceNavigationSchema>;
|
export type SpaceNavigation = z.infer<typeof SpaceNavigationSchema>;
|
||||||
|
|
||||||
@ -939,6 +949,8 @@ export const ThreadSnapshotSchema = z.object({
|
|||||||
groupId: Id.optional(),
|
groupId: Id.optional(),
|
||||||
groupName: z.string().optional(),
|
groupName: z.string().optional(),
|
||||||
members: z.array(GroupMemberSchema).optional(),
|
members: z.array(GroupMemberSchema).optional(),
|
||||||
|
/** The member that answers when nobody is addressed (the main conversation's main bot). */
|
||||||
|
leadBotId: Id.optional(),
|
||||||
run: RunSchema.nullable(),
|
run: RunSchema.nullable(),
|
||||||
activeRuns: z.array(RunSchema).optional(),
|
activeRuns: z.array(RunSchema).optional(),
|
||||||
computer: ComputerStatusSchema.optional(),
|
computer: ComputerStatusSchema.optional(),
|
||||||
@ -1275,6 +1287,7 @@ export const AppBootstrapSchema = z.object({
|
|||||||
thread: ThreadSnapshotSchema.nullable(),
|
thread: ThreadSnapshotSchema.nullable(),
|
||||||
routines: z.array(RoutineSchema),
|
routines: z.array(RoutineSchema),
|
||||||
spaces: z.array(SpaceSchema),
|
spaces: z.array(SpaceSchema),
|
||||||
|
main: MainConversationRefSchema.nullable(),
|
||||||
});
|
});
|
||||||
export type AppBootstrap = z.infer<typeof AppBootstrapSchema>;
|
export type AppBootstrap = z.infer<typeof AppBootstrapSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -273,6 +273,8 @@ export const appContract = {
|
|||||||
listArchived: oc.output(z.array(BotSchema)),
|
listArchived: oc.output(z.array(BotSchema)),
|
||||||
get: oc.input(botId).output(BotSchema),
|
get: oc.input(botId).output(BotSchema),
|
||||||
create: oc.input(CreateBotInput).output(BotSchema),
|
create: oc.input(CreateBotInput).output(BotSchema),
|
||||||
|
/** The user's main bot, created with its main conversation in the home space when missing. */
|
||||||
|
ensureMain: oc.output(BotSchema),
|
||||||
duplicate: oc.input(botId).output(BotSchema),
|
duplicate: oc.input(botId).output(BotSchema),
|
||||||
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
|
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
|
||||||
update: oc.input(UpdateBotInput).output(BotSchema),
|
update: oc.input(UpdateBotInput).output(BotSchema),
|
||||||
|
|||||||
@ -89,6 +89,21 @@ describe("resolveGroupTargetBotIds", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual(["a"]);
|
).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets the lead answer when unmentioned, but not when someone is addressed", () => {
|
||||||
|
expect(resolveGroupTargetBotIds({ text: "hello team", members, defaultBotId: "c" })).toEqual([
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
resolveGroupTargetBotIds({ text: "@BotB take this", members, defaultBotId: "c" }),
|
||||||
|
).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first member when the lead is not a member", () => {
|
||||||
|
expect(resolveGroupTargetBotIds({ text: "hello team", members, defaultBotId: "gone" })).toEqual(
|
||||||
|
["a"],
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("inferHandoffTargetName", () => {
|
describe("inferHandoffTargetName", () => {
|
||||||
|
|||||||
@ -32,6 +32,8 @@ export function resolveGroupTargetBotIds(input: {
|
|||||||
members: GroupMemberRef[];
|
members: GroupMemberRef[];
|
||||||
/** Bot ids from typed mention chips (non-members are ignored for wake). */
|
/** Bot ids from typed mention chips (non-members are ignored for wake). */
|
||||||
explicitMentions?: string[];
|
explicitMentions?: string[];
|
||||||
|
/** Member that answers when nobody is addressed, such as a group's lead; else the first. */
|
||||||
|
defaultBotId?: string | null;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const membersById = new Map(input.members.map((member) => [member.id, member]));
|
const membersById = new Map(input.members.map((member) => [member.id, member]));
|
||||||
const targetIds = new Set<string>();
|
const targetIds = new Set<string>();
|
||||||
@ -49,8 +51,12 @@ export function resolveGroupTargetBotIds(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetIds.size === 0 && input.members[0]) {
|
if (targetIds.size === 0) {
|
||||||
targetIds.add(input.members[0].id);
|
const fallback =
|
||||||
|
input.defaultBotId && membersById.has(input.defaultBotId)
|
||||||
|
? input.defaultBotId
|
||||||
|
: input.members[0]?.id;
|
||||||
|
if (fallback) targetIds.add(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...targetIds];
|
return [...targetIds];
|
||||||
|
|||||||
@ -88,6 +88,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
["bots/listArchived"],
|
["bots/listArchived"],
|
||||||
["bots/get", { botId: "missing-bot" }],
|
["bots/get", { botId: "missing-bot" }],
|
||||||
["bots/create", botInput("Unauthenticated")],
|
["bots/create", botInput("Unauthenticated")],
|
||||||
|
["bots/ensureMain"],
|
||||||
["bots/duplicate", { botId: "missing-bot" }],
|
["bots/duplicate", { botId: "missing-bot" }],
|
||||||
["bots/update", { botId: "missing-bot", name: "Nope" }],
|
["bots/update", { botId: "missing-bot", name: "Nope" }],
|
||||||
["bots/builtinTools", { botId: "missing-bot" }],
|
["bots/builtinTools", { botId: "missing-bot" }],
|
||||||
@ -405,6 +406,29 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The main conversation's own refusals must not reveal it to another user: a foreign main
|
||||||
|
// conversation is answered exactly like a group that does not exist.
|
||||||
|
await rpc(app, owner, "bots/ensureMain");
|
||||||
|
const ownerMain = (await rpc<SpaceNavigation>(app, owner, "spaces/list")).main;
|
||||||
|
expect(ownerMain).not.toBeNull();
|
||||||
|
for (const [procedure, input] of [
|
||||||
|
["groups/archive", {}],
|
||||||
|
["groups/remove", {}],
|
||||||
|
["groups/update", { botIds: [intruderBot.id, ownerBot.id] }],
|
||||||
|
] as const) {
|
||||||
|
const missing = await raw(app, intruder, procedure, { ...input, groupId: "missing-group" });
|
||||||
|
const foreign = await raw(app, intruder, procedure, {
|
||||||
|
...input,
|
||||||
|
groupId: ownerMain!.groupId,
|
||||||
|
});
|
||||||
|
expect(foreign.status, procedure).toBe(missing.status);
|
||||||
|
expect(foreign.status, procedure).not.toBe(400);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(await rpc<SpaceNavigation>(app, intruder, "spaces/list")).main,
|
||||||
|
"an intruder has no main conversation of their own yet",
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
// A caller cannot pair their own bot with another workspace's run ID.
|
// A caller cannot pair their own bot with another workspace's run ID.
|
||||||
await expectDenied(app, intruder, "threads/answer", {
|
await expectDenied(app, intruder, "threads/answer", {
|
||||||
botId: intruderBot.id,
|
botId: intruderBot.id,
|
||||||
|
|||||||
@ -2430,6 +2430,110 @@ describeJourneys("required product journeys", () => {
|
|||||||
expect(remainingBotIds).not.toContain(artifactOwnerId);
|
expect(remainingBotIds).not.toContain(artifactOwnerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("55: the main bot leads the main conversation and addressed bots join it", async () => {
|
||||||
|
const cookie = await signup(app, `main-conversation-${stamp}@rakazo.test`, "Main Conversation");
|
||||||
|
const me = await rpc<Me>(app, cookie, "me");
|
||||||
|
type MainRef = { botId: string; groupId: string; spaceId: string };
|
||||||
|
// Reading navigation must not create a main bot before onboarding asks for one.
|
||||||
|
expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toBeNull();
|
||||||
|
const mainBot = await rpc<Bot & { kind: string }>(app, cookie, "bots/ensureMain");
|
||||||
|
expect(mainBot.kind).toBe("main");
|
||||||
|
expect((await rpc<Bot>(app, cookie, "bots/ensureMain")).id).toBe(mainBot.id);
|
||||||
|
const { main } = await rpc<{ main: MainRef | null }>(app, cookie, "bootstrap", {});
|
||||||
|
expect(main).toMatchObject({ botId: mainBot.id, spaceId: me.spaceId });
|
||||||
|
expect((await rpc<{ main: MainRef | null }>(app, cookie, "spaces/list")).main).toEqual(main);
|
||||||
|
const groupId = main!.groupId;
|
||||||
|
const { id: threadId } = await prisma.thread.findUniqueOrThrow({
|
||||||
|
where: { groupId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const analyst = await rpc<Bot>(app, cookie, "bots/create", {
|
||||||
|
name: "Analyst",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
const userRunBots = async () =>
|
||||||
|
(
|
||||||
|
await prisma.run.findMany({
|
||||||
|
where: { threadId, trigger: "user" },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { botId: true },
|
||||||
|
})
|
||||||
|
).map((run) => run.botId);
|
||||||
|
|
||||||
|
// The lead answers when nobody is addressed, even with a single member.
|
||||||
|
await sendGroupAndWait(app, cookie, groupId, "hello there");
|
||||||
|
expect(await userRunBots()).toEqual([mainBot.id]);
|
||||||
|
|
||||||
|
// Addressing a same-space bot makes it join the conversation and answer there.
|
||||||
|
await sendGroupAndWait(app, cookie, groupId, "@Analyst check the numbers", analyst.id);
|
||||||
|
expect(await userRunBots()).toEqual([mainBot.id, analyst.id]);
|
||||||
|
const joined = await rpc<{ leadBotId?: string; members: Array<{ botId: string }> }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"threads/get",
|
||||||
|
{ groupId },
|
||||||
|
);
|
||||||
|
expect(joined.leadBotId).toBe(mainBot.id);
|
||||||
|
expect(joined.members.map((member) => member.botId).sort()).toEqual(
|
||||||
|
[mainBot.id, analyst.id].sort(),
|
||||||
|
);
|
||||||
|
expect(await prisma.event.count({ where: { threadId, type: "group.updated" } })).toBe(1);
|
||||||
|
expect(
|
||||||
|
await prisma.message.count({ where: { threadId, role: "bot", botId: analyst.id } }),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// A chip for a member routes only to that member.
|
||||||
|
await rpc(app, cookie, "threads/send", {
|
||||||
|
groupId,
|
||||||
|
text: "and the totals",
|
||||||
|
mentions: [{ kind: "bot", id: analyst.id }],
|
||||||
|
});
|
||||||
|
await waitForDatabase(async () => (await userRunBots()).length === 3);
|
||||||
|
expect((await userRunBots()).at(-1)).toBe(analyst.id);
|
||||||
|
|
||||||
|
for (const [procedure, input] of [
|
||||||
|
["groups/update", { groupId, botIds: [mainBot.id, analyst.id] }],
|
||||||
|
["groups/archive", { groupId }],
|
||||||
|
["groups/remove", { groupId }],
|
||||||
|
["bots/archive", { botId: mainBot.id }],
|
||||||
|
["bots/remove", { botId: mainBot.id }],
|
||||||
|
] as const) {
|
||||||
|
expect((await raw(app, cookie, procedure, input)).status, procedure).toBe(400);
|
||||||
|
}
|
||||||
|
await rpc(app, cookie, "groups/update", { groupId, pinned: true });
|
||||||
|
|
||||||
|
await rpc(app, cookie, "bots/update", { botId: mainBot.id, name: "Ada" });
|
||||||
|
expect((await prisma.chatGroup.findUniqueOrThrow({ where: { id: groupId } })).name).toBe("Ada");
|
||||||
|
|
||||||
|
// A member leaving never dissolves the main conversation.
|
||||||
|
await waitForDatabase(
|
||||||
|
async () =>
|
||||||
|
(await prisma.run.count({
|
||||||
|
where: { threadId, status: { in: [...ACTIVE_RUN_STATUSES] } },
|
||||||
|
})) === 0,
|
||||||
|
);
|
||||||
|
await rpc(app, cookie, "bots/remove", { botId: analyst.id, deleteMemories: true });
|
||||||
|
const afterLeave = await rpc<{ members: Array<{ botId: string }> }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"threads/get",
|
||||||
|
{
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(afterLeave.members.map((member) => member.botId)).toEqual([mainBot.id]);
|
||||||
|
|
||||||
|
await rpc(app, cookie, "threads/clear", { groupId });
|
||||||
|
const cleared = await prisma.event.findFirstOrThrow({
|
||||||
|
where: { threadId, type: "thread.cleared" },
|
||||||
|
orderBy: { seq: "desc" },
|
||||||
|
});
|
||||||
|
expect(cleared.botId).toBe(mainBot.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("17: teach a task end to end", async () => {
|
it("17: teach a task end to end", async () => {
|
||||||
const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada");
|
const cookie = await signup(app, `teach-j-${stamp}@rakazo.test`, "Teach Ada");
|
||||||
const bot = await rpc<Bot>(app, cookie, "bots/create", {
|
const bot = await rpc<Bot>(app, cookie, "bots/create", {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user