feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX

This commit is contained in:
cynarAI 2026-10-18 21:04:17 +02:00
parent 1611fe5fa2
commit f6053dd2a8
25 changed files with 861 additions and 328 deletions

View File

@ -135,6 +135,7 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
ensureMainConversation,
@ -783,6 +784,16 @@ export function createRouter(deps: RouterDeps) {
const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (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,
@ -793,6 +804,7 @@ export function createRouter(deps: RouterDeps) {
},
});
}
}
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(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.

View File

@ -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();
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,6 +155,8 @@ export default function GroupSettingsScreen() {
fontSize: 16,
}}
/>
{!isMainConversation ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
@ -106,25 +166,16 @@ export default function GroupSettingsScreen() {
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,6 +185,21 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")}
</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={{
@ -147,6 +213,7 @@ export default function GroupSettingsScreen() {
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
) : null}
</ScrollView>
</>
);

View File

@ -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]}
>

View File

@ -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 " });
});
});

View File

@ -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)) {

View File

@ -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", () => {

View File

@ -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 });

View File

@ -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"}>

View File

@ -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>
<div className="min-w-0 flex-1 text-start">
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
{error ? (
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive">
<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>
);
}

View File

@ -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 = "";

View File

@ -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;
// 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",

View File

@ -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;
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;
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)]" : ""}`}
>
{!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,7 +3721,12 @@ export function ShellPage() {
}
className="app-no-drag flex min-w-0 items-center gap-3"
>
{(!inGroup && active) ||
{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={
@ -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">
<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"
? coordinatorBot?.name === "Chief"
? "Projektkoordinator"
: (coordinatorBot?.name ??
(activeGroup.name === "Chief"
? "Projektkoordinator"
? 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 && active) ||
{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"

View File

@ -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

View File

@ -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"

View File

@ -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>
);

View File

@ -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>

View File

@ -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;

View File

@ -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

View File

@ -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)

View File

@ -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");
});
});

View File

@ -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 {

View File

@ -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" }),

View File

@ -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);
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;
let main = await findMainBot(prisma, userId, actor.spaceId);
if (!main) {
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) {

View File

@ -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 } }),

View File

@ -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,