feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX
This commit is contained in:
parent
1611fe5fa2
commit
f6053dd2a8
@ -135,6 +135,7 @@ import {
|
||||
createProjectGoalRepos,
|
||||
createRepos,
|
||||
createSpaceForMember,
|
||||
DuplicateSpaceNameError,
|
||||
deleteEmptySpaceForMember,
|
||||
deleteUnreferencedCredentialSecret,
|
||||
ensureMainConversation,
|
||||
@ -783,15 +784,26 @@ export function createRouter(deps: RouterDeps) {
|
||||
const goal =
|
||||
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
|
||||
if (goal) {
|
||||
await deps.prisma.memoryDocument.create({
|
||||
data: {
|
||||
spaceId: space.id,
|
||||
userId: context.actor.userId,
|
||||
scope: "user",
|
||||
path: "PROJECT_GOAL.md",
|
||||
content: goal,
|
||||
},
|
||||
const existingGoal = await deps.prisma.memoryDocument.findFirst({
|
||||
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingGoal) {
|
||||
await deps.prisma.memoryDocument.update({
|
||||
where: { id: existingGoal.id },
|
||||
data: { content: goal },
|
||||
});
|
||||
} else {
|
||||
await deps.prisma.memoryDocument.create({
|
||||
data: {
|
||||
spaceId: space.id,
|
||||
userId: context.actor.userId,
|
||||
scope: "user",
|
||||
path: "PROJECT_GOAL.md",
|
||||
content: goal,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// A project starts as a conversation with its own main agent.
|
||||
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
|
||||
@ -873,7 +885,7 @@ export function createRouter(deps: RouterDeps) {
|
||||
throw new ORPCError("NOT_FOUND", { message: error.message });
|
||||
}
|
||||
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
|
||||
if (error instanceof InvalidSpaceNameError) {
|
||||
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: error.message });
|
||||
}
|
||||
throw error;
|
||||
@ -5623,13 +5635,13 @@ async function spaceNavigationDto(
|
||||
// The user's own main agent of a project is part of the project, not content to clear.
|
||||
deps.prisma.bot.findMany({
|
||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||
select: { spaceId: true },
|
||||
distinct: ["spaceId"],
|
||||
select: { spaceId: true, userId: true },
|
||||
distinct: ["spaceId", "userId"],
|
||||
}),
|
||||
deps.prisma.chatGroup.findMany({
|
||||
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
|
||||
select: { spaceId: true },
|
||||
distinct: ["spaceId"],
|
||||
select: { spaceId: true, userId: true },
|
||||
distinct: ["spaceId", "userId"],
|
||||
}),
|
||||
deps.prisma.memoryDocument.findMany({
|
||||
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
|
||||
@ -5640,6 +5652,11 @@ async function spaceNavigationDto(
|
||||
...contentBots.map((row) => row.spaceId),
|
||||
...contentGroups.map((row) => row.spaceId),
|
||||
]);
|
||||
const spacesBlockedByOtherMember = new Set(
|
||||
[...contentBots, ...contentGroups]
|
||||
.filter((row) => row.userId !== actor.userId)
|
||||
.map((row) => row.spaceId),
|
||||
);
|
||||
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
|
||||
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
|
||||
if (!currentMembership) throw new IsolationError();
|
||||
@ -5676,7 +5693,7 @@ async function spaceNavigationDto(
|
||||
membership.role === "owner" &&
|
||||
!membership.space.isDefault &&
|
||||
memberships.length > 1 &&
|
||||
!spacesWithContent.has(membership.spaceId) &&
|
||||
!spacesBlockedByOtherMember.has(membership.spaceId) &&
|
||||
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
||||
canEdit: membership.role === "owner",
|
||||
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
||||
|
||||
@ -3,8 +3,15 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
|
||||
import { BotMemberPicker } from "../components/bot-member-picker";
|
||||
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
|
||||
import {
|
||||
type MobileBot,
|
||||
type MobileGroup,
|
||||
type MobileSpace,
|
||||
type MobileSpaceNavigation,
|
||||
rpc,
|
||||
} from "../lib/api";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
|
||||
export default function GroupSettingsScreen() {
|
||||
@ -13,12 +20,16 @@ export default function GroupSettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { groupId } = useLocalSearchParams<{ groupId: string }>();
|
||||
const [group, setGroup] = useState<MobileGroup | null>(null);
|
||||
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
|
||||
const [bots, setBots] = useState<MobileBot[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const isMainConversation = group?.kind === "main";
|
||||
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
void Promise.all([
|
||||
@ -26,11 +37,19 @@ export default function GroupSettingsScreen() {
|
||||
(groups) => groups.find((row) => row.id === groupId) ?? null,
|
||||
),
|
||||
rpc<MobileBot[]>("bots/list"),
|
||||
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
|
||||
])
|
||||
.then(([nextGroup, nextBots]) => {
|
||||
.then(([nextGroup, nextBots, navigation]) => {
|
||||
if (!nextGroup) throw new Error(t("Group not found"));
|
||||
setGroup(nextGroup);
|
||||
setName(nextGroup.name);
|
||||
const space =
|
||||
navigation?.spaces.find(
|
||||
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
|
||||
) ?? null;
|
||||
setProjectSpace(space);
|
||||
setName(
|
||||
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
|
||||
);
|
||||
setSelected(nextGroup.members.map((member) => member.botId));
|
||||
setBots(nextBots.filter((bot) => !bot.archivedAt));
|
||||
})
|
||||
@ -42,10 +61,20 @@ export default function GroupSettingsScreen() {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const trimmed = name.trim();
|
||||
if (isProjectMain && projectSpace) {
|
||||
if (trimmed && trimmed !== projectSpace.name) {
|
||||
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
|
||||
}
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
|
||||
if (name.trim() !== group.name) input.name = name.trim();
|
||||
const memberIds = group.members.map((member) => member.botId).join(",");
|
||||
if (selected.join(",") !== memberIds) input.botIds = selected;
|
||||
if (trimmed !== group.name) input.name = trimmed;
|
||||
if (!isMainConversation) {
|
||||
const memberIds = group.members.map((member) => member.botId).join(",");
|
||||
if (selected.join(",") !== memberIds) input.botIds = selected;
|
||||
}
|
||||
if (input.name || input.botIds) await rpc("groups/update", input);
|
||||
router.back();
|
||||
} catch (err) {
|
||||
@ -57,6 +86,29 @@ export default function GroupSettingsScreen() {
|
||||
|
||||
function remove() {
|
||||
if (!groupId || !group) return;
|
||||
if (isProjectMain && projectSpace) {
|
||||
Alert.alert(
|
||||
t("Delete {name}?", { name: projectSpace.name }),
|
||||
t("This removes the empty space for everyone."),
|
||||
[
|
||||
{ text: t("Cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("Delete"),
|
||||
style: "destructive",
|
||||
onPress: () =>
|
||||
void removeInboxSpace(projectSpace.id, async () => {})
|
||||
.then(() => router.replace("/"))
|
||||
.catch((err) =>
|
||||
Alert.alert(
|
||||
t("Could not delete space"),
|
||||
err instanceof Error ? err.message : t("Try again."),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
|
||||
{ text: t("Cancel"), style: "cancel" },
|
||||
{
|
||||
@ -75,6 +127,12 @@ export default function GroupSettingsScreen() {
|
||||
]);
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
!name.trim() ||
|
||||
pending ||
|
||||
(!isMainConversation &&
|
||||
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t("Group settings") }} />
|
||||
@ -97,34 +155,27 @@ export default function GroupSettingsScreen() {
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
|
||||
{t("Members")}
|
||||
</Text>
|
||||
<BotMemberPicker
|
||||
bots={bots}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
disabled={pending}
|
||||
/>
|
||||
{!isMainConversation ? (
|
||||
<>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
|
||||
{t("Members")}
|
||||
</Text>
|
||||
<BotMemberPicker
|
||||
bots={bots}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
disabled={pending}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
|
||||
<Pressable
|
||||
onPress={() => void save()}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
selected.length < GROUP_MEMBER_MIN ||
|
||||
selected.length > GROUP_MEMBER_MAX ||
|
||||
pending
|
||||
}
|
||||
disabled={saveDisabled}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
backgroundColor: tokens.primary,
|
||||
opacity:
|
||||
!name.trim() ||
|
||||
selected.length < GROUP_MEMBER_MIN ||
|
||||
selected.length > GROUP_MEMBER_MAX ||
|
||||
pending
|
||||
? 0.5
|
||||
: 1,
|
||||
opacity: saveDisabled ? 0.5 : 1,
|
||||
borderRadius: 11,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
@ -134,19 +185,35 @@ export default function GroupSettingsScreen() {
|
||||
{pending ? t("Saving…") : t("Save")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
|
||||
</Pressable>
|
||||
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
|
||||
</Pressable>
|
||||
) : !isMainConversation ? (
|
||||
<Pressable
|
||||
onPress={remove}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 11,
|
||||
borderWidth: 1,
|
||||
borderColor: tokens.border,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -597,7 +597,21 @@ export default function Home() {
|
||||
}}
|
||||
disabled={spaceBusy}
|
||||
onPress={() => {
|
||||
if (item.space) void chooseInboxSpace(item.space.id);
|
||||
if (!item.space) return;
|
||||
const targetSpace = item.space;
|
||||
const main = !targetSpace.isDefault
|
||||
? targetSpace.groups.find((group) => group.kind === "main")
|
||||
: undefined;
|
||||
if (main) {
|
||||
void openMobileSpace(targetSpace.id, () =>
|
||||
router.push({
|
||||
pathname: "/group-thread",
|
||||
params: { groupId: main.id, name: targetSpace.name },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void chooseInboxSpace(targetSpace.id);
|
||||
}}
|
||||
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
|
||||
>
|
||||
|
||||
@ -147,12 +147,18 @@ describe("spaceInboxItems", () => {
|
||||
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
|
||||
});
|
||||
|
||||
it("offers deletion for an explicitly authorized empty non-default space", () => {
|
||||
it("offers deletion for an explicitly authorized non-default space", () => {
|
||||
expect(canDeleteInboxSpace(space())).toBe(true);
|
||||
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("hides deletion while a space still has content", () => {
|
||||
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
|
||||
it("deduplicates same-named projects into a single project entry", () => {
|
||||
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
|
||||
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
|
||||
const items = spaceInboxItems([first, second]);
|
||||
const headings = items.filter((item) => item.type === "heading" && item.space);
|
||||
expect(headings).toHaveLength(1);
|
||||
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -24,22 +24,47 @@ export type InboxSpaceItem =
|
||||
| { type: "heading"; key: string; title: string; space?: InboxSpace };
|
||||
|
||||
export function canDeleteInboxSpace(
|
||||
space: Pick<InboxSpace, "isDefault" | "hasContent" | "canDelete">,
|
||||
space: Pick<InboxSpace, "isDefault" | "canDelete"> & Partial<Pick<InboxSpace, "hasContent">>,
|
||||
) {
|
||||
return !space.isDefault && !space.hasContent && space.canDelete === true;
|
||||
return !space.isDefault && space.canDelete === true;
|
||||
}
|
||||
|
||||
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
|
||||
return spaces.flatMap((space): InboxSpaceItem[] => {
|
||||
const seenProjectNames = new Set<string>();
|
||||
const canonicalProjectIds = new Set<string>();
|
||||
const orderedProjects = [...spaces]
|
||||
.filter((space) => !space.isDefault)
|
||||
.sort((left, right) => Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent)));
|
||||
for (const space of orderedProjects) {
|
||||
const normalized = space.name.trim().toLowerCase();
|
||||
if (normalized && seenProjectNames.has(normalized)) continue;
|
||||
if (normalized) seenProjectNames.add(normalized);
|
||||
canonicalProjectIds.add(space.id);
|
||||
}
|
||||
|
||||
const visibleSpaces = spaces.filter(
|
||||
(space) => space.isDefault || canonicalProjectIds.has(space.id),
|
||||
);
|
||||
|
||||
return visibleSpaces.flatMap((space): InboxSpaceItem[] => {
|
||||
const normalizedSpaceName = space.name.trim().toLowerCase();
|
||||
const chats = [
|
||||
// A main bot is reached through its space's main conversation, not listed again.
|
||||
...space.bots
|
||||
.filter((chat) => chat.kind !== "main")
|
||||
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
|
||||
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
|
||||
...space.groups
|
||||
.filter((chat) => {
|
||||
const normalizedGroupName = chat.name.trim().toLowerCase();
|
||||
if (!space.isDefault && normalizedGroupName === normalizedSpaceName) return false;
|
||||
if (space.isDefault && chat.kind !== "main" && seenProjectNames.has(normalizedGroupName))
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
|
||||
];
|
||||
const items: InboxSpaceItem[] = [];
|
||||
if (spaces.length > 1 || !space.isDefault || chats.length === 0) {
|
||||
if (visibleSpaces.length > 1 || !space.isDefault || chats.length === 0) {
|
||||
items.push({ type: "heading", key: space.id, title: space.name, space });
|
||||
}
|
||||
for (const group of groupBotsForSidebar(chats, space.botSections)) {
|
||||
|
||||
@ -67,7 +67,7 @@ test("main conversation does not show a group subtitle for one Oo", async ({ pag
|
||||
await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo");
|
||||
await completeOnboarding(page, undefined, { mainConversation: true });
|
||||
await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung");
|
||||
await expect(page.getByTestId("oo-group-stage")).not.toContainText("Gruppenunterhaltung");
|
||||
await expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.describe("mobile shell", () => {
|
||||
|
||||
@ -26,7 +26,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
|
||||
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 expect(page.getByTestId("oo-group-stage")).toHaveCount(0);
|
||||
await expect(page.getByTestId("bot-settings-trigger")).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "main-conversation-landing");
|
||||
|
||||
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });
|
||||
|
||||
@ -17,7 +17,7 @@ export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMo
|
||||
return (
|
||||
<p
|
||||
dir="auto"
|
||||
className="text-balance text-center text-ui-title font-medium leading-snug text-foreground"
|
||||
className="line-clamp-3 text-pretty text-start text-ui-body font-medium leading-snug text-foreground"
|
||||
>
|
||||
{captionWords(text).map((word, index) => (
|
||||
<span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}>
|
||||
|
||||
@ -90,21 +90,52 @@ export function VoiceStage({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
<section
|
||||
data-testid="call-view"
|
||||
data-theme="dark"
|
||||
data-phase={phase}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-3 overflow-y-auto bg-background px-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col items-center justify-end gap-2.5 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-foreground"
|
||||
>
|
||||
<h1 id={titleId} className="sr-only">
|
||||
{botName}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
id="voice-transcript-panel"
|
||||
className={`pointer-events-auto w-full max-w-lg overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card/95 px-5 py-4 text-start shadow-xl backdrop-blur-xl">
|
||||
{transcriptLines.length ? (
|
||||
<ul className="space-y-2.5">
|
||||
{transcriptLines.map((line, index) => (
|
||||
<li
|
||||
key={`${line.role}-${index}`}
|
||||
className={`text-ui-body-sm leading-relaxed ${
|
||||
line.role === "user" ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="text-ui-label text-muted-foreground">
|
||||
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
|
||||
</span>
|
||||
<span dir="auto" className="mt-0.5 block">
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Your conversation will appear here.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{awarenessLabel && awarenessEvent ? (
|
||||
<div className="w-full max-w-md">
|
||||
<div className="pointer-events-auto w-full max-w-lg">
|
||||
<VoiceAwarenessStrip
|
||||
event={awarenessEvent}
|
||||
label={awarenessLabel}
|
||||
@ -116,7 +147,7 @@ export function VoiceStage({
|
||||
|
||||
<section
|
||||
data-testid="voice-panel"
|
||||
className={`relative isolate flex min-h-[min(68vh,30rem)] w-full max-w-md flex-col overflow-hidden rounded-[2rem] border border-border bg-card shadow-2xl ${reducedMotion ? "" : "voice-panel-in"}`}
|
||||
className={`pointer-events-auto relative isolate flex w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl backdrop-blur-xl ${reducedMotion ? "" : "voice-panel-in"}`}
|
||||
>
|
||||
<VoiceAurora
|
||||
botColor={botColor}
|
||||
@ -125,28 +156,30 @@ export function VoiceStage({
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-7 pt-10 pb-4">
|
||||
<div title={`${botName} · ${botTitle}`}>
|
||||
<div className="relative flex items-center gap-3.5 px-4 pt-3.5 pb-2.5">
|
||||
<div className="shrink-0" title={`${botName} · ${botTitle}`}>
|
||||
<BotAvatar
|
||||
color={botColor}
|
||||
identity={botId}
|
||||
status={botStatus}
|
||||
pose={mascotPose}
|
||||
size={88}
|
||||
size={44}
|
||||
/>
|
||||
</div>
|
||||
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
|
||||
{error ? (
|
||||
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 text-start">
|
||||
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
|
||||
{error ? (
|
||||
<p role="alert" className="mt-1 text-ui-body-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-2 px-4 pb-4">
|
||||
<div className="relative flex items-center gap-2 px-4 pb-3">
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="mr-auto truncate rounded-full bg-foreground/10 px-3.5 py-2 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
|
||||
className="mr-auto truncate rounded-full bg-foreground/10 px-3 py-1.5 text-ui-body-sm font-medium text-foreground/85 backdrop-blur-md"
|
||||
>
|
||||
{phaseLabel}
|
||||
</span>
|
||||
@ -187,39 +220,6 @@ export function VoiceStage({
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
id="voice-transcript-panel"
|
||||
className={`w-full max-w-md overflow-hidden transition-[max-height,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
transcriptOpen ? "max-h-56 opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="rk-scroll max-h-52 overflow-y-auto rounded-3xl border border-border bg-card px-5 py-4 text-start">
|
||||
{transcriptLines.length ? (
|
||||
<ul className="space-y-2.5">
|
||||
{transcriptLines.map((line, index) => (
|
||||
<li
|
||||
key={`${line.role}-${index}`}
|
||||
className={`text-ui-body-sm leading-relaxed ${
|
||||
line.role === "user" ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="text-ui-label text-muted-foreground">
|
||||
{line.role === "user" ? t`You` : (line.speakerName ?? botName)}
|
||||
</span>
|
||||
<span dir="auto" className="mt-0.5 block">
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Your conversation will appear here.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,37 @@ import {
|
||||
StreamingVad,
|
||||
} from "@rakazo/core";
|
||||
import { selectedSpaceId, withSpaceHeaders } from "./rpc.js";
|
||||
import { readUiLocale } from "./ui-locale.js";
|
||||
|
||||
const SPEECH_LOCALE_TAGS: Record<string, string> = {
|
||||
de: "de-DE",
|
||||
en: "en-US",
|
||||
es: "es-ES",
|
||||
fr: "fr-FR",
|
||||
hi: "hi-IN",
|
||||
it: "it-IT",
|
||||
ja: "ja-JP",
|
||||
ko: "ko-KR",
|
||||
"pt-BR": "pt-BR",
|
||||
ru: "ru-RU",
|
||||
tr: "tr-TR",
|
||||
"zh-CN": "zh-CN",
|
||||
};
|
||||
|
||||
export function speechRecognitionLang(): string {
|
||||
const uiLocale = readUiLocale();
|
||||
const mapped = uiLocale ? SPEECH_LOCALE_TAGS[uiLocale] : undefined;
|
||||
if (uiLocale && mapped) {
|
||||
// Prefer browser's regional variant when it matches the UI language (e.g. de-AT / de-CH)
|
||||
const navLang = typeof navigator !== "undefined" ? navigator.language : "";
|
||||
const primary = uiLocale.split("-")[0]?.toLowerCase() ?? "";
|
||||
if (navLang?.toLowerCase().startsWith(`${primary}-`)) {
|
||||
return navLang;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
return (typeof navigator !== "undefined" && navigator.language) || "de-DE";
|
||||
}
|
||||
|
||||
export type DictationMode = "hold" | "endpoint";
|
||||
|
||||
@ -205,7 +236,7 @@ export class Dictation {
|
||||
const rec = new Ctor();
|
||||
rec.continuous = true;
|
||||
rec.interimResults = true;
|
||||
rec.lang = navigator.language || "en-US";
|
||||
rec.lang = speechRecognitionLang();
|
||||
rec.onresult = (event) => {
|
||||
if (this.token !== mine) return;
|
||||
let transcript = "";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core";
|
||||
import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js";
|
||||
import { readUiLocale } from "./ui-locale.js";
|
||||
|
||||
export type SpeechStatus = "idle" | "preparing" | "speaking";
|
||||
|
||||
@ -257,15 +258,57 @@ export class Speaker {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
const voices = window.speechSynthesis.getVoices?.() ?? [];
|
||||
const isGerman = /[äöüß]|(?:und|der|die|das|ist|nicht|für|mit|den|dem|des)/i.test(text);
|
||||
const langPrefix = isGerman ? "de" : "en";
|
||||
const uiLocale = readUiLocale();
|
||||
const voiceId = opts.voiceId ?? "";
|
||||
const isGermanVoiceId = /^d[fm]_/i.test(voiceId);
|
||||
const isGermanText =
|
||||
/[äöüß]|(?:\b(?:und|der|die|das|ist|nicht|für|mit|den|dem|des|ein|eine|ich|wir|bitte)\b)/i.test(
|
||||
text,
|
||||
);
|
||||
const isGerman = isGermanVoiceId || uiLocale === "de" || isGermanText;
|
||||
const langPrefix = isGerman ? "de" : uiLocale.split("-")[0] || "en";
|
||||
utterance.lang = isGerman ? "de-DE" : langPrefix === "en" ? "en-US" : uiLocale;
|
||||
|
||||
const langVoices = voices.filter((v) =>
|
||||
v.lang.toLowerCase().replace(/_/g, "-").startsWith(langPrefix),
|
||||
);
|
||||
const wantsMale = /^dm_/i.test(voiceId);
|
||||
const wantsFemale = /^df_/i.test(voiceId);
|
||||
const maleHint = /yannick|martin|markus|stefan|viktor|hans|daniel|thomas|male/i;
|
||||
const femaleHint = /anna|petra|helena|marlene|vicki|katja|siri|eloquence|female/i;
|
||||
|
||||
const matchedVoice =
|
||||
voices.find((v) => v.lang.startsWith(langPrefix) && !v.localService) ||
|
||||
voices.find((v) => v.lang.startsWith(langPrefix)) ||
|
||||
(wantsMale &&
|
||||
(langVoices.find((v) => !v.localService && maleHint.test(v.name)) ||
|
||||
langVoices.find((v) => maleHint.test(v.name)))) ||
|
||||
(wantsFemale &&
|
||||
(langVoices.find((v) => !v.localService && femaleHint.test(v.name)) ||
|
||||
langVoices.find((v) => femaleHint.test(v.name)))) ||
|
||||
langVoices.find((v) => !v.localService) ||
|
||||
langVoices[0] ||
|
||||
voices[0];
|
||||
if (matchedVoice) utterance.voice = matchedVoice;
|
||||
utterance.rate = 1.05;
|
||||
utterance.pitch = 1.0;
|
||||
|
||||
// Distinct natural voice profiles for the 5 German presets when rendered via system TTS
|
||||
if (voiceId === "dm_karlsson") {
|
||||
utterance.rate = 0.98;
|
||||
utterance.pitch = 0.85;
|
||||
} else if (voiceId === "dm_thorsten") {
|
||||
utterance.rate = 1.03;
|
||||
utterance.pitch = 0.95;
|
||||
} else if (voiceId === "df_ramona") {
|
||||
utterance.rate = 0.97;
|
||||
utterance.pitch = 1.0;
|
||||
} else if (voiceId === "df_eva") {
|
||||
utterance.rate = 1.02;
|
||||
utterance.pitch = 1.04;
|
||||
} else if (voiceId === "df_kerstin") {
|
||||
utterance.rate = 1.05;
|
||||
utterance.pitch = 1.08;
|
||||
} else {
|
||||
utterance.rate = 1.04;
|
||||
utterance.pitch = 1.0;
|
||||
}
|
||||
|
||||
this.set({
|
||||
status: "speaking",
|
||||
|
||||
@ -97,7 +97,7 @@ import { InAppBrowser } from "../components/browser/InAppBrowser";
|
||||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||||
import { DualSurfaceDivider } from "../components/DualSurfaceDivider";
|
||||
import { MainInbox } from "../components/MainInbox";
|
||||
import { OoGroupStage, OoHeaderStatus, OoStage } from "../components/stage/OoStage";
|
||||
import { OoHeaderStatus } from "../components/stage/OoStage";
|
||||
import { useDualSurfaceLayout } from "../hooks/useDualSurfaceLayout";
|
||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||
import { useWideLayout } from "../hooks/useWideLayout";
|
||||
@ -394,18 +394,6 @@ export function ShellPage() {
|
||||
const [screenHabit, setScreenHabit] = useState(0);
|
||||
const wideLayout = useWideLayout();
|
||||
|
||||
const handleSelectLayoutMode = useCallback(
|
||||
(mode: DualSurfaceLayoutMode) => {
|
||||
dualSurface.setLayoutMode(mode);
|
||||
if (mode === "stream") {
|
||||
setPanel(null);
|
||||
} else if (mode === "split" || mode === "canvas") {
|
||||
setPanel((current) => current ?? "computer");
|
||||
}
|
||||
},
|
||||
[dualSurface],
|
||||
);
|
||||
|
||||
const prevPanelRef = useRef(panel);
|
||||
useEffect(() => {
|
||||
if (!prevPanelRef.current && panel && dualSurface.layoutMode === "stream") {
|
||||
@ -679,11 +667,13 @@ export function ShellPage() {
|
||||
activeGroup?.leadBotId ??
|
||||
activeGroup?.members?.[0]?.botId ??
|
||||
activeSpace.bots?.find((b) => b.kind === "main")?.id;
|
||||
return (
|
||||
const found =
|
||||
(leadId ? bots.find((b) => b.id === leadId) : undefined) ??
|
||||
bots.find((b) => b.kind === "main" && b.spaceId === activeSpace.id) ??
|
||||
bots.find((b) => b.spaceId === activeSpace.id)
|
||||
);
|
||||
bots.find((b) => b.spaceId === activeSpace.id);
|
||||
if (!found) return undefined;
|
||||
const isLegacyName = found.name === "Chief" || found.name === "Projektkoordinator";
|
||||
return isLegacyName ? { ...found, name: "Projektmanager" } : found;
|
||||
}, [
|
||||
inProject,
|
||||
activeSpace,
|
||||
@ -693,16 +683,35 @@ export function ShellPage() {
|
||||
bots,
|
||||
]);
|
||||
|
||||
const handleSelectLayoutMode = useCallback(
|
||||
(mode: DualSurfaceLayoutMode) => {
|
||||
dualSurface.setLayoutMode(mode);
|
||||
if (mode === "stream") {
|
||||
setPanel(null);
|
||||
} else if (mode === "split" || mode === "canvas") {
|
||||
setPanel((current) => current ?? (inProject ? null : "computer"));
|
||||
}
|
||||
},
|
||||
[dualSurface, inProject],
|
||||
);
|
||||
|
||||
const autoOpenedProjectSpaceRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (inProject && !dualSurface.isMobile && dualSurface.layoutMode === "stream") {
|
||||
const projectSpaceId = inProject ? (activeSpace?.id ?? "__project__") : null;
|
||||
if (!projectSpaceId) {
|
||||
autoOpenedProjectSpaceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (autoOpenedProjectSpaceRef.current === projectSpaceId) return;
|
||||
autoOpenedProjectSpaceRef.current = projectSpaceId;
|
||||
if (!dualSurface.isMobile && dualSurface.layoutMode === "stream") {
|
||||
dualSurface.setLayoutMode("split");
|
||||
}
|
||||
}, [inProject, dualSurface]);
|
||||
}, [inProject, activeSpace?.id, dualSurface]);
|
||||
const activeProjectSection = activeGroup?.sectionId
|
||||
? botSections.find((section) => section.id === activeGroup.sectionId)
|
||||
: undefined;
|
||||
const showProjectTimeline =
|
||||
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
|
||||
const showProjectTimeline = false;
|
||||
const activePendingAttachments = useMemo(
|
||||
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
|
||||
[active?.id, groupId, inGroup, pendingAttachments],
|
||||
@ -1953,9 +1962,57 @@ export function ShellPage() {
|
||||
status: member.status,
|
||||
}));
|
||||
}, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]);
|
||||
const projectGroupMembers: GroupAvatarMember[] = useMemo(() => {
|
||||
const byId = new Map<string, GroupAvatarMember>();
|
||||
const rawMembers = activeGroup?.members ?? activeSnapshot?.members ?? [];
|
||||
for (const member of rawMembers) {
|
||||
const bot = resolveSpeakerBot(member.botId);
|
||||
const name =
|
||||
member.botId === coordinatorBot?.id ? coordinatorBot.name : (member.name ?? bot?.name);
|
||||
byId.set(member.botId, {
|
||||
botId: member.botId,
|
||||
name,
|
||||
color: member.color || bot?.color || FALLBACK_BOT_COLOR,
|
||||
status: member.status ?? bot?.status,
|
||||
});
|
||||
}
|
||||
if (inProject && activeSpace) {
|
||||
for (const spaceBot of activeSpace.bots ?? []) {
|
||||
if (!byId.has(spaceBot.id)) {
|
||||
const name = spaceBot.id === coordinatorBot?.id ? coordinatorBot.name : spaceBot.name;
|
||||
byId.set(spaceBot.id, {
|
||||
botId: spaceBot.id,
|
||||
name,
|
||||
color: spaceBot.color || FALLBACK_BOT_COLOR,
|
||||
status: spaceBot.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const bot of bots) {
|
||||
if (bot.spaceId === activeSpace.id && !byId.has(bot.id)) {
|
||||
const name = bot.id === coordinatorBot?.id ? coordinatorBot.name : bot.name;
|
||||
byId.set(bot.id, {
|
||||
botId: bot.id,
|
||||
name,
|
||||
color: bot.color || FALLBACK_BOT_COLOR,
|
||||
status: bot.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [
|
||||
activeGroup?.members,
|
||||
activeSnapshot?.members,
|
||||
activeSpace,
|
||||
bots,
|
||||
coordinatorBot,
|
||||
inProject,
|
||||
resolveSpeakerBot,
|
||||
]);
|
||||
|
||||
const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen);
|
||||
const voiceEncounterActive = encounterMode || callOpen || voiceEntry;
|
||||
const encounterMode = false;
|
||||
const voiceEncounterActive = callOpen || voiceEntry;
|
||||
const voiceCallBot = useMemo(() => {
|
||||
if (active) return active;
|
||||
if (!inGroup || !activeSnapshot) return undefined;
|
||||
@ -1983,14 +2040,15 @@ export function ShellPage() {
|
||||
useEffect(() => {
|
||||
if (!voiceEntry || showProjectTimeline || callOpen) return;
|
||||
if (!voiceCallBot) return;
|
||||
if (voiceStatus?.ready) {
|
||||
voiceSetupPrompted.current = false;
|
||||
setCallOpen(true);
|
||||
return;
|
||||
voiceSetupPrompted.current = false;
|
||||
setCallOpen(true);
|
||||
if (!voiceStatus?.ready) {
|
||||
void rpc.voice
|
||||
.connect({ provider: "kokoro", apiKey: "local-kokoro" })
|
||||
.then(() => rpc.voice.status())
|
||||
.then(setVoiceStatus)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (voiceSetupPrompted.current) return;
|
||||
voiceSetupPrompted.current = true;
|
||||
openSettings("voice");
|
||||
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
|
||||
|
||||
const heuteActionCount = useHeuteActionCount(groups, botsRef);
|
||||
@ -2005,9 +2063,15 @@ export function ShellPage() {
|
||||
});
|
||||
|
||||
const resolveTranscriptMemberName = useCallback(
|
||||
(botId: string | undefined) =>
|
||||
memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined),
|
||||
[knownBots, transcriptMembers],
|
||||
(botId: string | undefined) => {
|
||||
if (botId && coordinatorBot && botId === coordinatorBot.id) {
|
||||
return coordinatorBot.name;
|
||||
}
|
||||
return (
|
||||
memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined)
|
||||
);
|
||||
},
|
||||
[coordinatorBot, knownBots, transcriptMembers],
|
||||
);
|
||||
const replyTargetName = activeReplyTarget
|
||||
? activeReplyTarget.role === "user"
|
||||
@ -2124,11 +2188,14 @@ export function ShellPage() {
|
||||
[groupId, navigate, searchParams, setSearchParams],
|
||||
);
|
||||
const startVoiceEncounter = useCallback(() => {
|
||||
if (voiceStatus?.ready) {
|
||||
setCallOpen(true);
|
||||
return;
|
||||
setCallOpen(true);
|
||||
if (!voiceStatus?.ready) {
|
||||
void rpc.voice
|
||||
.connect({ provider: "kokoro", apiKey: "local-kokoro" })
|
||||
.then(() => rpc.voice.status())
|
||||
.then(setVoiceStatus)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
openSettings("voice");
|
||||
}, [voiceStatus?.ready]);
|
||||
|
||||
const closeProjectSession = useCallback(() => {
|
||||
@ -3026,24 +3093,27 @@ export function ShellPage() {
|
||||
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
|
||||
maxWidth: mobileSidebarOpen ? 316 : undefined,
|
||||
}}
|
||||
className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${
|
||||
className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:relative md:z-auto md:translate-x-0 ${
|
||||
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
|
||||
} ${
|
||||
botsSidebarCollapsed
|
||||
? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none"
|
||||
? "md:w-0 md:max-w-0 md:overflow-hidden md:border-0 md:-me-3 md:opacity-0 md:pointer-events-none"
|
||||
: ""
|
||||
} ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
|
||||
>
|
||||
<div
|
||||
onPointerDown={startResizingLeft}
|
||||
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||||
/>
|
||||
{!botsSidebarCollapsed ? (
|
||||
<div
|
||||
onPointerDown={startResizingLeft}
|
||||
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||||
/>
|
||||
) : null}
|
||||
<SidebarHeader
|
||||
activityMode={activityMode}
|
||||
onToggleActivityMode={toggleActivityMode}
|
||||
createMenuOpen={createMenuOpen}
|
||||
onCreateMenuOpenChange={setCreateMenuOpen}
|
||||
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
|
||||
onCollapseSidebar={() => setBotsSidebarCollapsedPref(true)}
|
||||
bots={bots}
|
||||
onCreateBot={() => setPanel("create")}
|
||||
onOpenBot={(id) => navigate(`/app/${id}`)}
|
||||
@ -3315,6 +3385,15 @@ export function ShellPage() {
|
||||
name={projectSpace.name}
|
||||
icon={projectSpace.icon}
|
||||
color={projectSpace.color}
|
||||
members={(projectSpace.bots ?? []).map((b) => ({
|
||||
botId: b.id,
|
||||
name:
|
||||
b.name === "Chief" || b.name === "Projektkoordinator"
|
||||
? "Projektmanager"
|
||||
: b.name,
|
||||
color: b.color || FALLBACK_BOT_COLOR,
|
||||
status: b.status,
|
||||
}))}
|
||||
hasMenu={
|
||||
projectSpace.canEdit === true || projectSpace.canDelete === true
|
||||
}
|
||||
@ -3469,8 +3548,9 @@ export function ShellPage() {
|
||||
data-testid="bots-sidebar-edge"
|
||||
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`}
|
||||
aria-pressed={!botsSidebarCollapsed}
|
||||
style={botsSidebarCollapsed ? undefined : { insetInlineStart: `${leftSidebarWidth + 8}px` }}
|
||||
className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${
|
||||
botsSidebarCollapsed ? "start-0" : "start-[308px]"
|
||||
botsSidebarCollapsed ? "start-0" : ""
|
||||
}`}
|
||||
onPointerDown={onBotsSidebarEdgePointerDown}
|
||||
onPointerMove={onBotsSidebarEdgePointerMove}
|
||||
@ -3545,7 +3625,7 @@ export function ShellPage() {
|
||||
: showCanvasOnDesktop;
|
||||
|
||||
const dividerElement =
|
||||
!dualSurface.isMobile && dualSurface.layoutMode === "split" ? (
|
||||
!dualSurface.isMobile && showCanvasOnDesktop && dualSurface.layoutMode === "split" ? (
|
||||
<DualSurfaceDivider
|
||||
splitRatio={dualSurface.splitRatio}
|
||||
onResize={dualSurface.setSplitRatio}
|
||||
@ -3641,8 +3721,13 @@ export function ShellPage() {
|
||||
}
|
||||
className="app-no-drag flex min-w-0 items-center gap-3"
|
||||
>
|
||||
{(!inGroup && active) ||
|
||||
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? (
|
||||
{inGroup &&
|
||||
(inProject ||
|
||||
activeGroup?.kind !== "main" ||
|
||||
projectGroupMembers.length > 1) ? (
|
||||
<GroupAvatar members={projectGroupMembers} size={36} className="shrink-0" />
|
||||
) : (!inGroup && active) ||
|
||||
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? (
|
||||
<BotAvatar
|
||||
color={
|
||||
(inGroup ? coordinatorBot?.color : active?.color) ?? FALLBACK_BOT_COLOR
|
||||
@ -3653,43 +3738,73 @@ export function ShellPage() {
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : inGroup ? (
|
||||
<GroupAvatar
|
||||
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
|
||||
size={36}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<GroupAvatar members={projectGroupMembers} size={36} className="shrink-0" />
|
||||
) : null}
|
||||
<div className="min-w-0 text-start flex flex-col justify-center">
|
||||
<span
|
||||
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
|
||||
dir="auto"
|
||||
>
|
||||
{inGroup && activeGroup?.kind === "main"
|
||||
? coordinatorBot?.name === "Chief"
|
||||
? "Projektkoordinator"
|
||||
: (coordinatorBot?.name ??
|
||||
(activeGroup.name === "Chief"
|
||||
? "Projektkoordinator"
|
||||
: activeGroup.name))
|
||||
: inGroup
|
||||
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
|
||||
: (active?.name ?? t`Choose a conversation`)}
|
||||
</span>
|
||||
{(!inGroup && active) ||
|
||||
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
|
||||
dir="auto"
|
||||
>
|
||||
{inGroup && activeGroup?.kind === "main"
|
||||
? inProject
|
||||
? (activeSpace?.name ??
|
||||
coordinatorBot?.name ??
|
||||
(activeGroup.name === "Chief" ||
|
||||
activeGroup.name === "Projektkoordinator"
|
||||
? "Projektmanager"
|
||||
: activeGroup.name))
|
||||
: (coordinatorBot?.name ?? activeGroup.name)
|
||||
: inGroup
|
||||
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
|
||||
: (active?.name ?? t`Choose a conversation`)}
|
||||
</span>
|
||||
{inGroup &&
|
||||
(inProject ||
|
||||
activeGroup?.kind !== "main" ||
|
||||
projectGroupMembers.length > 1) &&
|
||||
projectGroupMembers.length > 0 ? (
|
||||
<div
|
||||
data-testid="group-header-avatars"
|
||||
className="flex shrink-0 items-center -space-x-1.5"
|
||||
>
|
||||
{projectGroupMembers.slice(0, 5).map((member) => (
|
||||
<div
|
||||
key={member.botId}
|
||||
title={member.name}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
>
|
||||
<BotAvatar
|
||||
color={member.color ?? FALLBACK_BOT_COLOR}
|
||||
identity={member.botId}
|
||||
size={18}
|
||||
status={member.status}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{inGroup &&
|
||||
(inProject || activeGroup?.kind !== "main") &&
|
||||
projectGroupMembers.length > 0 ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/85 leading-snug">
|
||||
{projectGroupMembers
|
||||
.map((m) => m.name)
|
||||
.filter(Boolean)
|
||||
.join(", ") || t`Group conversation`}
|
||||
</span>
|
||||
) : (!inGroup && active) ||
|
||||
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? (
|
||||
<OoHeaderStatus
|
||||
botName={
|
||||
(inGroup
|
||||
? coordinatorBot?.name === "Chief"
|
||||
? "Projektkoordinator"
|
||||
: coordinatorBot?.name
|
||||
: active?.name) ?? ""
|
||||
}
|
||||
botName={(inGroup ? coordinatorBot?.name : active?.name) ?? ""}
|
||||
botTitle={
|
||||
(inGroup
|
||||
? coordinatorBot?.title === "Project lead"
|
||||
? "Projektkoordinator"
|
||||
: (coordinatorBot?.title ?? "Projektkoordinator")
|
||||
? coordinatorBot?.title === "Project lead" ||
|
||||
coordinatorBot?.title === "Projektkoordinator" ||
|
||||
coordinatorBot?.title === "Projektmanager"
|
||||
? ""
|
||||
: (coordinatorBot?.title ?? "")
|
||||
: active?.title) ?? ""
|
||||
}
|
||||
botDescription={
|
||||
@ -3709,7 +3824,7 @@ export function ShellPage() {
|
||||
</span>
|
||||
) : inGroup && activeGroup?.kind !== "main" ? (
|
||||
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
|
||||
{t`Project · sessions & folders`}
|
||||
{t`Group conversation`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@ -3717,7 +3832,7 @@ export function ShellPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DualSurfaceHeaderControls
|
||||
layoutMode={dualSurface.layoutMode}
|
||||
layoutMode={showCanvasOnDesktop ? dualSurface.layoutMode : "stream"}
|
||||
orientation={dualSurface.orientation}
|
||||
onSelectMode={handleSelectLayoutMode}
|
||||
onToggleOrientation={dualSurface.toggleOrientation}
|
||||
@ -3753,6 +3868,15 @@ export function ShellPage() {
|
||||
title={t`Agent computer`}
|
||||
aria-label={t`Agent computer`}
|
||||
onClick={() => {
|
||||
if (inProject && !active) {
|
||||
if (showCanvasOnDesktop) {
|
||||
setPanel(null);
|
||||
dualSurface.setLayoutMode("stream");
|
||||
} else {
|
||||
dualSurface.setLayoutMode("split");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The computer belongs to a bot; from a group, open its lead's computer.
|
||||
if (!active && groupComputerBotId) {
|
||||
navigate(`/app/${groupComputerBotId}`);
|
||||
@ -3778,7 +3902,7 @@ export function ShellPage() {
|
||||
}
|
||||
}}
|
||||
data-active={
|
||||
panel === "computer" && dualSurface.layoutMode !== "stream"
|
||||
(panel === "computer" || (inProject && !active)) && showCanvasOnDesktop
|
||||
? ""
|
||||
: undefined
|
||||
}
|
||||
@ -3829,25 +3953,6 @@ export function ShellPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!callOpen && !inGroup && active && transcriptMessages.length === 0 ? (
|
||||
<OoStage
|
||||
botId={active.id}
|
||||
botName={active.name}
|
||||
botTitle={active.title}
|
||||
botDescription={active.description}
|
||||
botColor={active.color}
|
||||
botStatus={stageWaitingStatus ?? active.status}
|
||||
snapshot={activeSnapshot}
|
||||
running={stageRunning}
|
||||
/>
|
||||
) : null}
|
||||
{!callOpen && inGroup ? (
|
||||
<OoGroupStage
|
||||
groupName={activeGroup?.name ?? activeSnapshot?.groupName}
|
||||
snapshot={activeSnapshot}
|
||||
enrichMember={enrichStageMember}
|
||||
/>
|
||||
) : null}
|
||||
{active && activeDelegation ? (
|
||||
<div
|
||||
data-testid="swarm-activity-banner"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
|
||||
import { voiceIdForLocale, voicesForLocale } from "@rakazo/core";
|
||||
import { GERMAN_VOICE_PRESETS, voiceIdForLocale, voicesForLocale } from "@rakazo/core";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@ -113,12 +113,19 @@ export function VoiceSettingsOverlay({
|
||||
}, [activeBotId]);
|
||||
|
||||
const voiceOptions = useMemo(() => {
|
||||
const listed = voicesForLocale(voices, uiLocale);
|
||||
const isGermanLocale = uiLocale.toLowerCase().startsWith("de");
|
||||
const hasGermanVoices = voices.some((v) => /^d[fm]_/i.test(v.id));
|
||||
const combined =
|
||||
isGermanLocale && !hasGermanVoices ? [...GERMAN_VOICE_PRESETS, ...voices] : voices;
|
||||
const listed = voicesForLocale(combined, uiLocale);
|
||||
if (listed.length) return listed;
|
||||
if (isGermanLocale) return GERMAN_VOICE_PRESETS;
|
||||
return voiceId ? [{ id: voiceId, label: voiceId }] : [];
|
||||
}, [voices, voiceId, uiLocale]);
|
||||
|
||||
const speakingReady = Boolean(localConnected && voiceId && status?.ready);
|
||||
const speakingReady = Boolean(
|
||||
(localConnected && voiceId && status?.ready) || (autoSpeak && voiceOptions.length > 0),
|
||||
);
|
||||
|
||||
async function ensureLocalVoice(): Promise<boolean> {
|
||||
if (localConnected) return true;
|
||||
@ -133,6 +140,9 @@ export function VoiceSettingsOverlay({
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
||||
return true;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : t`Could not enable speaking`);
|
||||
return false;
|
||||
} finally {
|
||||
@ -142,7 +152,12 @@ export function VoiceSettingsOverlay({
|
||||
|
||||
async function chooseVoice(nextVoiceId: string) {
|
||||
setVoiceId(nextVoiceId);
|
||||
if (!localConnected) return;
|
||||
if (!localConnected) {
|
||||
if (activeBotId) {
|
||||
await rpc.bots.update({ botId: activeBotId, voiceId: nextVoiceId }).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
markPending("voice");
|
||||
setError(null);
|
||||
try {
|
||||
@ -161,6 +176,7 @@ export function VoiceSettingsOverlay({
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
let chosenVoice = voiceId;
|
||||
if (next) {
|
||||
const connected = await ensureLocalVoice();
|
||||
if (!connected) {
|
||||
@ -168,16 +184,29 @@ export function VoiceSettingsOverlay({
|
||||
return;
|
||||
}
|
||||
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []);
|
||||
const nextVoice = voiceIdForLocale(listed, uiLocale, voiceId);
|
||||
const isGermanLocale = uiLocale.toLowerCase().startsWith("de");
|
||||
const pool =
|
||||
isGermanLocale && !listed.some((v) => /^d[fm]_/i.test(v.id))
|
||||
? [...GERMAN_VOICE_PRESETS, ...listed]
|
||||
: listed;
|
||||
const nextVoice = voiceIdForLocale(pool, uiLocale, voiceId);
|
||||
if (nextVoice) {
|
||||
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID });
|
||||
chosenVoice = nextVoice;
|
||||
setVoiceId(nextVoice);
|
||||
await rpc.voice
|
||||
.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (activeBotId) {
|
||||
await rpc.bots.update({ botId: activeBotId, autoSpeak: next });
|
||||
await rpc.bots.update({
|
||||
botId: activeBotId,
|
||||
autoSpeak: next,
|
||||
...(chosenVoice ? { voiceId: chosenVoice } : {}),
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
if (!voiceId && chosenVoice) setVoiceId(chosenVoice);
|
||||
setNotice(next ? t`Speaking is on.` : t`Speaking is off.`);
|
||||
} catch (err) {
|
||||
setAutoSpeak(!next);
|
||||
@ -193,7 +222,11 @@ export function VoiceSettingsOverlay({
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
void speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`, {
|
||||
const isGermanVoice = /^d[fm]_/i.test(voice.id) || uiLocale.toLowerCase().startsWith("de");
|
||||
const sampleText = isGermanVoice
|
||||
? `Hallo, ich bin ${voice.label}. Lass uns direkt miteinander sprechen.`
|
||||
: t`Hi, this is how I'll sound when we talk.`;
|
||||
void speaker.speak(sampleText, {
|
||||
voiceId: voice.id,
|
||||
messageId: `${PREVIEW_PREFIX}${voice.id}`,
|
||||
});
|
||||
@ -234,7 +267,7 @@ export function VoiceSettingsOverlay({
|
||||
{autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>}
|
||||
</p>
|
||||
<p className="mt-1 text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Your Oo reads replies aloud when this is on.</Trans>
|
||||
<Trans>Your Oo talks with you naturally when this is on.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { Button } from "@rakazo/ui-web";
|
||||
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
||||
import { BotAvatar, Button } from "@rakazo/ui-web";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { ProjectIcon } from "../../components/project-icon";
|
||||
import { rosterNameClass, rosterRowSurfaceClass } from "./roster-row-style";
|
||||
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
||||
import type { SpaceMenuRequest } from "./sidebar-group-header";
|
||||
|
||||
/** One project in the sidebar: its icon and name; it opens the project's coordinator. */
|
||||
/** One project in the sidebar: its icon, name, and group member avatars; it opens the project's group chat. */
|
||||
export function ProjectRow({
|
||||
spaceId,
|
||||
name,
|
||||
icon,
|
||||
color,
|
||||
members,
|
||||
hasMenu,
|
||||
selected,
|
||||
unread,
|
||||
@ -23,6 +26,7 @@ export function ProjectRow({
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
members?: GroupAvatarMember[];
|
||||
hasMenu: boolean;
|
||||
selected: boolean;
|
||||
unread: boolean;
|
||||
@ -58,6 +62,27 @@ export function ProjectRow({
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{members && members.length > 0 ? (
|
||||
<div
|
||||
data-testid={`project-row-avatars-${spaceId}`}
|
||||
className="flex shrink-0 items-center -space-x-1.5"
|
||||
>
|
||||
{members.slice(0, 3).map((member) => (
|
||||
<div
|
||||
key={member.botId}
|
||||
title={member.name}
|
||||
className="rounded-full ring-2 ring-sidebar"
|
||||
>
|
||||
<BotAvatar
|
||||
color={member.color ?? FALLBACK_BOT_COLOR}
|
||||
identity={member.botId}
|
||||
size={20}
|
||||
status={member.status}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{needsYou > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { Bot } from "@rakazo/contracts";
|
||||
import { Popover, PopoverContent, PopoverTrigger, Wordmark } from "@rakazo/ui-web";
|
||||
import { Bell, Plus } from "lucide-react";
|
||||
import { Bell, PanelLeftClose, Plus } from "lucide-react";
|
||||
import { WindowChrome } from "../WindowChrome";
|
||||
import { BotCreatePicker } from "./bot-picker";
|
||||
|
||||
@ -12,6 +12,7 @@ export function SidebarHeader({
|
||||
createMenuOpen,
|
||||
onCreateMenuOpenChange,
|
||||
onCloseMobileSidebar,
|
||||
onCollapseSidebar,
|
||||
bots,
|
||||
onCreateBot,
|
||||
onOpenBot,
|
||||
@ -25,6 +26,7 @@ export function SidebarHeader({
|
||||
createMenuOpen: boolean;
|
||||
onCreateMenuOpenChange: (open: boolean) => void;
|
||||
onCloseMobileSidebar: () => void;
|
||||
onCollapseSidebar?: () => void;
|
||||
bots: Bot[];
|
||||
onCreateBot: () => void;
|
||||
onOpenBot: (id: string) => void;
|
||||
@ -40,7 +42,7 @@ export function SidebarHeader({
|
||||
<div className="flex-1 px-1">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-2.5">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Activity`}
|
||||
@ -113,6 +115,28 @@ export function SidebarHeader({
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</Popover>
|
||||
|
||||
{onCollapseSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="collapse-bots-sidebar"
|
||||
aria-label={t`Hide bots`}
|
||||
title={t`Hide bots`}
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.matchMedia("(min-width: 768px)").matches
|
||||
) {
|
||||
onCloseMobileSidebar();
|
||||
return;
|
||||
}
|
||||
onCollapseSidebar();
|
||||
}}
|
||||
className="app-no-drag rk-touch-target flex size-control-sm items-center justify-center rounded-full rk-sidebar-muted hover:text-foreground/75"
|
||||
>
|
||||
<PanelLeftClose strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -455,7 +455,13 @@ export const Transcript = memo(function Transcript({
|
||||
}`}
|
||||
>
|
||||
{reactionView.visibleMessages.map((message) => {
|
||||
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
||||
if (message.id.startsWith("progress:")) return null;
|
||||
if (
|
||||
!message.blocks.some(
|
||||
(block) => !isToolActivityBlock(block) && block.kind !== "progress",
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (runNarration.hidden.has(message.id)) return null;
|
||||
const foldedSteps = runNarration.steps.get(message.id);
|
||||
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
||||
@ -628,7 +634,7 @@ export const Transcript = memo(function Transcript({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{running && !encounterMode ? (
|
||||
{(running || messages.some((m) => m.id.startsWith("progress:"))) && !encounterMode ? (
|
||||
<div className="mt-2.5">
|
||||
<JobProgressCard
|
||||
bot={workingBots[0]}
|
||||
@ -924,7 +930,7 @@ const MessageView = memo(function MessageView({
|
||||
Boolean(foldedSteps && foldedSteps.length > 0) ||
|
||||
message.blocks.some((b) => b.kind === "steps");
|
||||
const visibleNarrationBlocks = message.blocks
|
||||
.filter((block) => !isToolActivityBlock(block))
|
||||
.filter((block) => !isToolActivityBlock(block) && block.kind !== "progress")
|
||||
.filter((block) => {
|
||||
// Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message
|
||||
if (hasSteps && block.kind === "text") {
|
||||
@ -944,14 +950,18 @@ const MessageView = memo(function MessageView({
|
||||
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
|
||||
const messageContext = (
|
||||
<>
|
||||
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
|
||||
{effectiveSpeakerName && message.role === "bot" ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => message.botId && onOpenBot(message.botId)}
|
||||
className="flex items-center gap-2.5 text-start hover:opacity-85 transition-opacity"
|
||||
>
|
||||
<div className="relative flex items-center justify-center size-8 rounded-full bg-foreground/[0.06] border border-border shadow-xs shrink-0">
|
||||
<BotAvatar
|
||||
color={speakerBot?.color || speakerColorDef.hex}
|
||||
identity={message.botId ?? "bot"}
|
||||
size={22}
|
||||
size={28}
|
||||
moment={isLive ? "working" : "idle"}
|
||||
/>
|
||||
</div>
|
||||
@ -966,7 +976,7 @@ const MessageView = memo(function MessageView({
|
||||
Oo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@ -974,16 +984,24 @@ const MessageView = memo(function MessageView({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
|
||||
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span dir="auto">{effectiveSpeakerName}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
|
||||
) : message.role === "user" && !encounterMode ? (
|
||||
<div className="mb-1.5 flex items-center justify-end gap-2">
|
||||
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
</span>
|
||||
<span className="text-ui-body-sm font-semibold text-foreground tracking-tight">
|
||||
{t`You`}
|
||||
</span>
|
||||
<div
|
||||
data-testid="user-avatar-badge"
|
||||
aria-hidden="true"
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-ui-label font-bold text-primary-foreground shadow-xs"
|
||||
>
|
||||
{t`You`.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
) : speakerName ? (
|
||||
<div
|
||||
@ -994,7 +1012,7 @@ const MessageView = memo(function MessageView({
|
||||
<BotAvatar
|
||||
color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
|
||||
identity={message.botId}
|
||||
size={22}
|
||||
size={24}
|
||||
/>
|
||||
{speakerName}
|
||||
</div>
|
||||
@ -1025,18 +1043,14 @@ const MessageView = memo(function MessageView({
|
||||
<div className="flex w-fit max-w-full justify-start">
|
||||
<div
|
||||
data-testid="message-bot-bubble"
|
||||
className={
|
||||
encounterMode
|
||||
? "max-w-full space-y-2 border-s-2 border-success/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
||||
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
||||
}
|
||||
className="chat-bubble-bot max-w-full space-y-3 rounded-[24px] rounded-tl-md px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
||||
dir="auto"
|
||||
>
|
||||
{visibleNarrationBlocks.map((block, i) => {
|
||||
if (block.kind === "text" || block.kind === "progress") {
|
||||
if (block.kind === "text") {
|
||||
return (
|
||||
<div key={i}>
|
||||
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown>
|
||||
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1147,17 +1161,7 @@ const MessageView = memo(function MessageView({
|
||||
);
|
||||
}
|
||||
if (block.kind === "progress") {
|
||||
return (
|
||||
<div key={i} className="flex w-fit max-w-full justify-start">
|
||||
<div
|
||||
data-testid="message-bot-bubble"
|
||||
className="chat-bubble-bot max-w-full rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
||||
dir="auto"
|
||||
>
|
||||
<ChatMarkdown streaming>{block.text}</ChatMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (block.kind === "subagent") {
|
||||
const running = block.status === "running";
|
||||
@ -1196,11 +1200,9 @@ const MessageView = memo(function MessageView({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
|
||||
{block.progress || block.result ? (
|
||||
{block.result ? (
|
||||
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
|
||||
<ChatMarkdown streaming={running}>
|
||||
{block.result || block.progress || ""}
|
||||
</ChatMarkdown>
|
||||
<ChatMarkdown>{block.result}</ChatMarkdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -1349,9 +1351,7 @@ const MessageView = memo(function MessageView({
|
||||
<div
|
||||
data-testid="message-user-bubble"
|
||||
className={cn(
|
||||
encounterMode
|
||||
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
|
||||
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-ui-reading leading-[1.62] text-chat-user-foreground shadow-md",
|
||||
"chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] rounded-tr-md px-5 py-3.5 text-ui-body leading-[1.62] text-chat-user-foreground shadow-md",
|
||||
isOptimistic && "opacity-80",
|
||||
)}
|
||||
dir="auto"
|
||||
@ -1373,11 +1373,7 @@ const MessageView = memo(function MessageView({
|
||||
<div key={i} className="flex w-fit max-w-full justify-start">
|
||||
<div
|
||||
data-testid="message-bot-bubble"
|
||||
className={
|
||||
encounterMode
|
||||
? "max-w-full space-y-2 border-s-2 border-success/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
||||
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
|
||||
}
|
||||
className="chat-bubble-bot max-w-full space-y-3 rounded-[24px] rounded-tl-md px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
|
||||
dir="auto"
|
||||
>
|
||||
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||||
|
||||
@ -149,6 +149,7 @@ export function useSidebarModel({
|
||||
if (!homeSpaceId) return [];
|
||||
const entries: typeof sidebarGroups = [];
|
||||
const seenProjectSpaceIds = new Set<string>();
|
||||
const seenProjectNames = new Set<string>();
|
||||
const seenProjectGroupChatIds = new Set<string>();
|
||||
|
||||
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
|
||||
@ -161,8 +162,29 @@ export function useSidebarModel({
|
||||
});
|
||||
};
|
||||
|
||||
const orderedSpaces = [...spaces].sort((left, right) => {
|
||||
const leftActive = Number(left.id === bootstrapMe?.spaceId);
|
||||
const rightActive = Number(right.id === bootstrapMe?.spaceId);
|
||||
if (leftActive !== rightActive) return rightActive - leftActive;
|
||||
return Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent));
|
||||
});
|
||||
const canonicalSpaceIds = new Set<string>();
|
||||
for (const space of orderedSpaces) {
|
||||
if (space.id === homeSpaceId) continue;
|
||||
const normalized = space.name.trim().toLowerCase();
|
||||
if (normalized && seenProjectNames.has(normalized)) continue;
|
||||
if (normalized) seenProjectNames.add(normalized);
|
||||
canonicalSpaceIds.add(space.id);
|
||||
}
|
||||
|
||||
for (const space of spaces) {
|
||||
if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue;
|
||||
if (
|
||||
space.id === homeSpaceId ||
|
||||
!canonicalSpaceIds.has(space.id) ||
|
||||
seenProjectSpaceIds.has(space.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seenProjectSpaceIds.add(space.id);
|
||||
const groupsInSpace = sidebarGroups.flatMap((group) =>
|
||||
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [],
|
||||
@ -192,6 +214,8 @@ export function useSidebarModel({
|
||||
const groupChats = dedupeSidebarBots(
|
||||
group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"),
|
||||
).filter((item) => {
|
||||
const normalized = item.chat.name.trim().toLowerCase();
|
||||
if (normalized && seenProjectNames.has(normalized)) return false;
|
||||
if (seenProjectGroupChatIds.has(item.chat.id)) return false;
|
||||
seenProjectGroupChatIds.add(item.chat.id);
|
||||
return true;
|
||||
|
||||
@ -71,6 +71,25 @@ export function formatWorkingPrinciples(toolNames: ReadonlySet<string>): string
|
||||
export const ROUTINE_RUN_INSTRUCTION =
|
||||
"A schedule started this run; nobody is waiting in the chat and earlier messages are not shown. Do what the task says with your tools, then post a short result the user can read later. If the task only asks for news and there is none, say so in one line.";
|
||||
|
||||
/**
|
||||
* When the user is in a live voice conversation or the bot speaks its replies, the agent must
|
||||
* talk like a real person in a conversation instead of reading written prose or markdown aloud.
|
||||
*/
|
||||
export function formatVoiceConversationInstruction(input: {
|
||||
voiceTurn?: boolean;
|
||||
autoSpeak?: boolean;
|
||||
}): string | undefined {
|
||||
if (!input.voiceTurn && !input.autoSpeak) return undefined;
|
||||
return [
|
||||
"Voice conversation mode is active — your reply is spoken directly to the user:",
|
||||
"- Communicate naturally as in a real spoken dialogue, never like someone reading an article or documentation aloud.",
|
||||
"- Keep spoken replies concise, warm, and direct (1–3 natural sentences unless the user explicitly asks for a detailed explanation).",
|
||||
"- Do not use bullet lists, numbered lists, headings, tables, or markdown formatting in conversational prose.",
|
||||
"- Never read out file paths, URLs, or code blocks. If code or structured output is needed, include a single clean code block for the screen and summarize the takeaway in one natural spoken sentence.",
|
||||
"- Match the user's language (e.g., natural conversational German when the user speaks German).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user connected, named from the tools this run actually received, so the agent
|
||||
* neither denies a connected app nor claims one whose tools failed to load. Apps reached
|
||||
|
||||
@ -253,6 +253,7 @@ import {
|
||||
formatMainAgentRole,
|
||||
formatSubagentContext,
|
||||
formatUserContext,
|
||||
formatVoiceConversationInstruction,
|
||||
formatWorkingPrinciples,
|
||||
ONBOARDING_OPEN_INSTRUCTION,
|
||||
ROUTINE_RUN_INSTRUCTION,
|
||||
@ -4127,6 +4128,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
instructions: [
|
||||
formatBotIdentity(bot),
|
||||
formatWorkingPrinciples(exposedToolNames),
|
||||
formatVoiceConversationInstruction({
|
||||
autoSpeak: Boolean(bot.autoSpeak),
|
||||
}),
|
||||
bot.instructions,
|
||||
bot.kind === "main"
|
||||
? formatMainAgentRole(exposedToolNames, mainAgentProject)
|
||||
|
||||
@ -226,4 +226,24 @@ describe("spokenDecision", () => {
|
||||
expect(spokenDecision("I'm sure that's fine, go look at the logs")).toBeNull();
|
||||
expect(spokenDecision("")).toBeNull();
|
||||
});
|
||||
|
||||
it("understands German yes and no responses", () => {
|
||||
expect(spokenDecision("ja")).toBe("yes");
|
||||
expect(spokenDecision("Ja, mach das bitte")).toBe("yes");
|
||||
expect(spokenDecision("klar, weiter")).toBe("yes");
|
||||
expect(spokenDecision("einverstanden")).toBe("yes");
|
||||
expect(spokenDecision("nein")).toBe("no");
|
||||
expect(spokenDecision("Nein danke")).toBe("no");
|
||||
expect(spokenDecision("Stopp, abbrechen")).toBe("no");
|
||||
expect(spokenDecision("lass das lieber")).toBe("no");
|
||||
});
|
||||
|
||||
it("localizes code block and link placeholders when the text is German", () => {
|
||||
const out = speakable(
|
||||
"Hier ist die Lösung für das Problem:\n\n```ts\nconst x = 1;\n```\n\nMehr unter https://example.com/docs.",
|
||||
);
|
||||
expect(out).toContain("ein TypeScript Codeblock");
|
||||
expect(out).toContain("ein Link");
|
||||
expect(out).not.toContain("const x");
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,8 +2,15 @@ import type { MessageBlock } from "@rakazo/contracts";
|
||||
import { sanitizeBotReplyText } from "./bot-reply-sanitize.js";
|
||||
import { isToolActivityBlock } from "./tool-activity.js";
|
||||
|
||||
const GERMAN_PROSE =
|
||||
/[äöüß]|(?:\b(?:und|der|die|das|ist|nicht|für|mit|den|dem|des|ein|eine|ich|hier|bitte|oder)\b)/i;
|
||||
|
||||
function isGermanText(text: string): boolean {
|
||||
return GERMAN_PROSE.test(text);
|
||||
}
|
||||
|
||||
/** A fenced block becomes a mention of itself, with its language if known. */
|
||||
function describeCodeBlock(fence: string): string {
|
||||
function describeCodeBlock(fence: string, german = false): string {
|
||||
const lang =
|
||||
fence
|
||||
.trim()
|
||||
@ -34,6 +41,9 @@ function describeCodeBlock(fence: string): string {
|
||||
css: "CSS",
|
||||
};
|
||||
const name = spoken[lang.toLowerCase()];
|
||||
if (german) {
|
||||
return name ? `. (ein ${name} Codeblock) ` : ". (ein Codeblock) ";
|
||||
}
|
||||
return name ? `. (a ${name} code block) ` : ". (a code block) ";
|
||||
}
|
||||
|
||||
@ -54,17 +64,24 @@ export function speakable(input: string): string {
|
||||
if (!input) return "";
|
||||
let text = sanitizeBotReplyText(input).text;
|
||||
if (!text) return "";
|
||||
const german = isGermanText(text);
|
||||
|
||||
text = text.replace(/(`{3,}|~{3,})([^\n]*)\n[\s\S]*?(?:\1|$)/g, (_m, _marker, fence: string) =>
|
||||
describeCodeBlock(fence),
|
||||
describeCodeBlock(fence, german),
|
||||
);
|
||||
|
||||
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_m, alt: string) =>
|
||||
alt ? `. (image: ${alt}) ` : ". (an image) ",
|
||||
alt
|
||||
? german
|
||||
? `. (Bild: ${alt}) `
|
||||
: `. (image: ${alt}) `
|
||||
: german
|
||||
? ". (ein Bild) "
|
||||
: ". (an image) ",
|
||||
);
|
||||
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/<https?:\/\/[^>\s]+>/g, " a link ");
|
||||
text = text.replace(/\bhttps?:\/\/\S+/g, " a link ");
|
||||
text = text.replace(/<https?:\/\/[^>\s]+>/g, german ? " ein Link " : " a link ");
|
||||
text = text.replace(/\bhttps?:\/\/\S+/g, german ? " ein Link " : " a link ");
|
||||
|
||||
text = text.replace(/^\s*\|?[\s:-]*\|[\s|:-]*$/gm, "");
|
||||
text = text.replace(/^\s*\|(.+)\|\s*$/gm, (_m, row: string) =>
|
||||
@ -76,7 +93,7 @@ export function speakable(input: string): string {
|
||||
);
|
||||
|
||||
text = text.replace(/`([^`\n]+)`/g, (_m, code: string) =>
|
||||
code.length <= 40 ? code : " that snippet ",
|
||||
code.length <= 40 ? code : german ? " dieser Ausschnitt " : " that snippet ",
|
||||
);
|
||||
|
||||
text = text.replace(/^\s{0,3}#{1,6}\s+(.*)$/gm, (_m, head: string) =>
|
||||
@ -114,7 +131,7 @@ export function speakable(input: string): string {
|
||||
}
|
||||
|
||||
const BOUNDARY =
|
||||
/(?<!\b(?:e\.g|i\.e|etc|vs|Dr|Mr|Mrs|Ms|No|approx))(?<![.\d])([.!?])(["')\]]*)\s+/g;
|
||||
/(?<!\b(?:e\.g|i\.e|etc|vs|Dr|Mr|Mrs|Ms|No|approx|z\.B|bzw|usw|ca|Nr|ggf|inkl|vgl))(?<![.\d])([.!?])(["')\]]*)\s+/g;
|
||||
|
||||
/**
|
||||
* Split speakable text into utterances a synthesizer can start on.
|
||||
@ -263,8 +280,10 @@ export function speechFromBlocks(blocks: MessageBlock[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const YES = /^(yes|yeah|yep|yup|ok|okay|sure|do it|go ahead|approve|confirmed|affirmative)\b/i;
|
||||
const NO = /^(no|nope|nah|cancel|stop|don't|do not|reject|deny|negative)\b/i;
|
||||
const YES =
|
||||
/^(yes|yeah|yep|yup|ok|okay|sure|do it|go ahead|approve|confirmed|affirmative|ja|jap|jo|jawohl|klar|gerne|mach das|weiter|einverstanden|passt|bestätigt|genehmigt|los geht's|auf jeden fall)\b/i;
|
||||
const NO =
|
||||
/^(no|nope|nah|cancel|stop|don't|do not|reject|deny|negative|nein|nee|nö|nicht|stopp|abbrechen|lass das|bloß nicht|ablehnen|auf keinen fall)\b/i;
|
||||
|
||||
/** Consent must never be inferred from a sentence that merely contained "sure". */
|
||||
export function spokenDecision(transcript: string): "yes" | "no" | null {
|
||||
|
||||
@ -123,6 +123,15 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => {
|
||||
}),
|
||||
).toEqual({ name: "Backyard", icon: "leaf", color: "green" });
|
||||
|
||||
const other = await createSpaceForMember(prisma, {
|
||||
currentSpaceId: homeSpaceId,
|
||||
userId,
|
||||
name: "Orchard",
|
||||
});
|
||||
await expect(
|
||||
updateSpaceForOwner(prisma, { userId, spaceId: other.id, name: " backyard " }),
|
||||
).rejects.toThrow("A project with this name already exists");
|
||||
|
||||
const stranger = await newUser("Eve");
|
||||
await expect(
|
||||
updateSpaceForOwner(prisma, { userId: stranger.userId, spaceId: project.id, name: "Mine" }),
|
||||
|
||||
@ -18,7 +18,7 @@ export const MAIN_BOT_DEFAULTS = {
|
||||
|
||||
/** Name and role of a project's main bot, the one the user talks to about that project. */
|
||||
export const PROJECT_MAIN_BOT_DEFAULTS = {
|
||||
name: "Chief",
|
||||
name: "Projektmanager",
|
||||
title: "Project lead",
|
||||
description: "Leads this project and hands its work to specialists.",
|
||||
} as const;
|
||||
@ -114,13 +114,13 @@ export async function ensureMainBot(
|
||||
spaceId?: string,
|
||||
): Promise<MainBotRef> {
|
||||
const actor = await requireMembership(prisma, userId, spaceId);
|
||||
const space = await prisma.space.findUnique({
|
||||
where: { id: actor.spaceId },
|
||||
select: { isDefault: true },
|
||||
});
|
||||
const home = space?.isDefault === true || spaceId === undefined;
|
||||
let main = await findMainBot(prisma, userId, actor.spaceId);
|
||||
if (!main) {
|
||||
const space = await prisma.space.findUnique({
|
||||
where: { id: actor.spaceId },
|
||||
select: { isDefault: true },
|
||||
});
|
||||
const home = space?.isDefault === true || spaceId === undefined;
|
||||
const first = home
|
||||
? await prisma.bot.findFirst({
|
||||
where: {
|
||||
@ -161,8 +161,16 @@ export async function ensureMainBot(
|
||||
main = await findMainBot(prisma, userId, actor.spaceId);
|
||||
if (!main) throw new IsolationError("Main bot could not be created");
|
||||
}
|
||||
const botUpdates: { archivedAt?: null; name?: string } = {};
|
||||
if (main.archivedAt) {
|
||||
await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: null } });
|
||||
botUpdates.archivedAt = null;
|
||||
}
|
||||
if (!home && (main.name === MAIN_BOT_DEFAULTS.name || main.name === "Projektkoordinator")) {
|
||||
botUpdates.name = PROJECT_MAIN_BOT_DEFAULTS.name;
|
||||
main = { ...main, name: PROJECT_MAIN_BOT_DEFAULTS.name };
|
||||
}
|
||||
if (botUpdates.archivedAt !== undefined || botUpdates.name !== undefined) {
|
||||
await prisma.bot.update({ where: { id: main.id }, data: botUpdates });
|
||||
}
|
||||
if (!main.thread) throw new IsolationError("Main bot is missing its thread");
|
||||
return { id: main.id, spaceId: main.spaceId, threadId: main.thread.id, name: main.name };
|
||||
@ -176,6 +184,7 @@ async function findMainConversation(
|
||||
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
spaceId: true,
|
||||
leadBotId: true,
|
||||
archivedAt: true,
|
||||
@ -222,10 +231,20 @@ export async function ensureMainConversation(
|
||||
}
|
||||
group = await findMainConversation(prisma, key);
|
||||
} else {
|
||||
const repairs: { leadBotId?: string; archivedAt?: null } = {};
|
||||
const repairs: { leadBotId?: string; archivedAt?: null; name?: string } = {};
|
||||
if (group.leadBotId !== main.id) repairs.leadBotId = main.id;
|
||||
if (group.archivedAt) repairs.archivedAt = null;
|
||||
if (repairs.leadBotId !== undefined || repairs.archivedAt !== undefined) {
|
||||
if (
|
||||
main.name === PROJECT_MAIN_BOT_DEFAULTS.name &&
|
||||
(group.name === MAIN_BOT_DEFAULTS.name || group.name === "Projektkoordinator")
|
||||
) {
|
||||
repairs.name = main.name;
|
||||
}
|
||||
if (
|
||||
repairs.leadBotId !== undefined ||
|
||||
repairs.archivedAt !== undefined ||
|
||||
repairs.name !== undefined
|
||||
) {
|
||||
await prisma.chatGroup.update({ where: { id: group.id }, data: repairs });
|
||||
}
|
||||
if (group.members.length === 0) {
|
||||
|
||||
@ -20,6 +20,13 @@ export class InvalidSpaceNameError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateSpaceNameError extends Error {
|
||||
constructor() {
|
||||
super("A project with this name already exists");
|
||||
this.name = "DuplicateSpaceNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("Space not found");
|
||||
@ -210,10 +217,28 @@ export async function updateSpaceForOwner(
|
||||
if (name !== undefined && (!name || name.length > 60)) throw new InvalidSpaceNameError();
|
||||
const membership = await prisma.spaceMember.findUnique({
|
||||
where: { spaceId_userId: { spaceId: input.spaceId, userId: input.userId } },
|
||||
select: { role: true, space: { select: { deletingAt: true } } },
|
||||
select: {
|
||||
role: true,
|
||||
organizationId: true,
|
||||
space: { select: { deletingAt: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!membership || membership.space.deletingAt) throw new SpaceNotFoundError();
|
||||
if (membership.role !== "owner") throw new IsolationError();
|
||||
if (name !== undefined && !membership.space.isDefault) {
|
||||
const existing = await prisma.space.findFirst({
|
||||
where: {
|
||||
organizationId: membership.organizationId,
|
||||
id: { not: input.spaceId },
|
||||
name: { equals: name, mode: "insensitive" },
|
||||
isDefault: false,
|
||||
deletingAt: null,
|
||||
memberships: { some: { userId: input.userId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) throw new DuplicateSpaceNameError();
|
||||
}
|
||||
await prisma.space.update({
|
||||
where: { id: input.spaceId },
|
||||
data: {
|
||||
@ -399,9 +424,7 @@ async function assertEmptySpaceDeletable(
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (memberships.length <= 1) throw new CannotDeleteLastSpaceError();
|
||||
const exceptOwnMain = options.exceptOwnMain
|
||||
? { NOT: { userId: input.userId, kind: "main" } }
|
||||
: {};
|
||||
const exceptOwnMain = options.exceptOwnMain ? { NOT: { userId: input.userId } } : {};
|
||||
const [botCount, groupCount, computers] = await Promise.all([
|
||||
db.bot.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
|
||||
db.chatGroup.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
|
||||
@ -423,17 +446,16 @@ async function assertEmptySpaceDeletable(
|
||||
}
|
||||
|
||||
/**
|
||||
* The deleting user's own main bots and main conversations in a Space that holds nothing else
|
||||
* and passes every other deletion check. A project's main agent is part of the project, so the
|
||||
* caller removes these before claiming the Space; nothing is returned while other content or
|
||||
* another rule would still block the deletion.
|
||||
* The deleting user's own bots and conversations in a Space that holds no other member's content
|
||||
* and passes every other deletion check. The caller removes these before claiming the Space;
|
||||
* nothing is returned while another member's content or another rule would still block deletion.
|
||||
*/
|
||||
export async function ownMainAgentsBlockingSpaceDeletion(
|
||||
prisma: PrismaClient,
|
||||
input: EmptySpaceDeleteInput,
|
||||
): Promise<{ botIds: string[]; groupIds: string[] }> {
|
||||
await assertEmptySpaceDeletable(prisma, input, { exceptOwnMain: true });
|
||||
const own = { spaceId: input.spaceId, userId: input.userId, kind: "main" };
|
||||
const own = { spaceId: input.spaceId, userId: input.userId };
|
||||
const [bots, groups] = await Promise.all([
|
||||
prisma.bot.findMany({ where: own, select: { id: true } }),
|
||||
prisma.chatGroup.findMany({ where: own, select: { id: true } }),
|
||||
|
||||
@ -1675,28 +1675,19 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
expect(await missing.text()).toMatch(/credential/i);
|
||||
});
|
||||
|
||||
it("deletes only empty, non-default spaces", async () => {
|
||||
it("deletes non-default spaces owned by the member and blocks when another member has content", async () => {
|
||||
const cookie = await signup(app, `space-delete-${stamp}@rakazo.test`, "Space Delete");
|
||||
const actor = await rpc<Actor>(app, cookie, "me");
|
||||
const empty = await rpc<Space>(app, cookie, "spaces/create", { name: "Temporary" });
|
||||
const busy = await rpc<Space>(app, cookie, "spaces/create", { name: "Busy" });
|
||||
const busyBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Busy bot"), busy.id);
|
||||
|
||||
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
await expect(
|
||||
handles.prisma.space.findUnique({ where: { id: busy.id } }),
|
||||
).resolves.not.toBeNull();
|
||||
await rpc(app, cookie, "bots/archive", { botId: busyBot.id }, busy.id);
|
||||
expect(
|
||||
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, busy.id)).spaces.find(
|
||||
(space) => space.id === busy.id,
|
||||
),
|
||||
).toMatchObject({ hasContent: true, canDelete: false });
|
||||
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
).toMatchObject({ hasContent: true, canDelete: true });
|
||||
await expect(
|
||||
raw(app, cookie, "spaces/remove", { spaceId: actor.spaceId }),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
@ -1727,6 +1718,16 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const busyRemoved = await rpc<{ ok: true; activeSpaceId: string }>(
|
||||
app,
|
||||
cookie,
|
||||
"spaces/remove",
|
||||
{ spaceId: busy.id },
|
||||
);
|
||||
expect(busyRemoved).toEqual({ ok: true, activeSpaceId: actor.spaceId });
|
||||
await expect(handles.prisma.space.findUnique({ where: { id: busy.id } })).resolves.toBeNull();
|
||||
expect(await handles.prisma.bot.count({ where: { spaceId: busy.id } })).toBe(0);
|
||||
|
||||
// Shared-space members must not delete; only the SpaceMember owner may.
|
||||
const shared = await rpc<Space>(app, cookie, "spaces/create", { name: "Shared empty" });
|
||||
expect(shared.canDelete).toBe(true);
|
||||
@ -1767,12 +1768,12 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
handles.prisma.space.findUnique({ where: { id: shared.id } }),
|
||||
).resolves.not.toBeNull();
|
||||
|
||||
// Another member's bot still counts as content for onboarding emptiness.
|
||||
const ownerBot = await rpc<Bot>(
|
||||
// Another member's bot counts as content and blocks the owner from deleting the shared space.
|
||||
const memberBot = await rpc<Bot>(
|
||||
app,
|
||||
cookie,
|
||||
memberCookie,
|
||||
"bots/create",
|
||||
botInput("Owner shared bot"),
|
||||
botInput("Member shared bot"),
|
||||
shared.id,
|
||||
);
|
||||
const ownerNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id);
|
||||
@ -1781,6 +1782,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
expect.objectContaining({ id: shared.id, hasContent: true, canDelete: false }),
|
||||
]),
|
||||
);
|
||||
await expect(raw(app, cookie, "spaces/remove", { spaceId: shared.id })).resolves.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
const memberNavigation = await rpc<SpaceNavigation>(
|
||||
app,
|
||||
memberCookie,
|
||||
@ -1794,7 +1798,13 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
await rpc(app, cookie, "bots/remove", { botId: ownerBot.id, deleteMemories: true }, shared.id);
|
||||
await rpc(
|
||||
app,
|
||||
memberCookie,
|
||||
"bots/remove",
|
||||
{ botId: memberBot.id, deleteMemories: true },
|
||||
shared.id,
|
||||
);
|
||||
expect(
|
||||
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id)).spaces.find(
|
||||
(space) => space.id === shared.id,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user