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, createProjectGoalRepos,
createRepos, createRepos,
createSpaceForMember, createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember, deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret, deleteUnreferencedCredentialSecret,
ensureMainConversation, ensureMainConversation,
@ -783,15 +784,26 @@ export function createRouter(deps: RouterDeps) {
const goal = const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null); input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (goal) { if (goal) {
await deps.prisma.memoryDocument.create({ const existingGoal = await deps.prisma.memoryDocument.findFirst({
data: { where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
spaceId: space.id, select: { id: true },
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
}); });
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. // A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id); 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 }); throw new ORPCError("NOT_FOUND", { message: error.message });
} }
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN"); 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 new ORPCError("BAD_REQUEST", { message: error.message });
} }
throw error; 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. // The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({ deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true, userId: true },
distinct: ["spaceId"], distinct: ["spaceId", "userId"],
}), }),
deps.prisma.chatGroup.findMany({ deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true, userId: true },
distinct: ["spaceId"], distinct: ["spaceId", "userId"],
}), }),
deps.prisma.memoryDocument.findMany({ deps.prisma.memoryDocument.findMany({
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" }, where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
@ -5640,6 +5652,11 @@ async function spaceNavigationDto(
...contentBots.map((row) => row.spaceId), ...contentBots.map((row) => row.spaceId),
...contentGroups.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 goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId); const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
if (!currentMembership) throw new IsolationError(); if (!currentMembership) throw new IsolationError();
@ -5676,7 +5693,7 @@ async function spaceNavigationDto(
membership.role === "owner" && membership.role === "owner" &&
!membership.space.isDefault && !membership.space.isDefault &&
memberships.length > 1 && memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) && !spacesBlockedByOtherMember.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
canEdit: membership.role === "owner", canEdit: membership.role === "owner",
// A linked folder is on the owner's host; other members see neither its path nor remote. // 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 { useEffect, useState } from "react";
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native"; import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
import { BotMemberPicker } from "../components/bot-member-picker"; 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 { useI18n } from "../lib/i18n";
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
import { useMobileTokens } from "../lib/native"; import { useMobileTokens } from "../lib/native";
export default function GroupSettingsScreen() { export default function GroupSettingsScreen() {
@ -13,12 +20,16 @@ export default function GroupSettingsScreen() {
const router = useRouter(); const router = useRouter();
const { groupId } = useLocalSearchParams<{ groupId: string }>(); const { groupId } = useLocalSearchParams<{ groupId: string }>();
const [group, setGroup] = useState<MobileGroup | null>(null); const [group, setGroup] = useState<MobileGroup | null>(null);
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
const [bots, setBots] = useState<MobileBot[]>([]); const [bots, setBots] = useState<MobileBot[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const isMainConversation = group?.kind === "main";
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
useEffect(() => { useEffect(() => {
if (!groupId) return; if (!groupId) return;
void Promise.all([ void Promise.all([
@ -26,11 +37,19 @@ export default function GroupSettingsScreen() {
(groups) => groups.find((row) => row.id === groupId) ?? null, (groups) => groups.find((row) => row.id === groupId) ?? null,
), ),
rpc<MobileBot[]>("bots/list"), 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")); if (!nextGroup) throw new Error(t("Group not found"));
setGroup(nextGroup); 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)); setSelected(nextGroup.members.map((member) => member.botId));
setBots(nextBots.filter((bot) => !bot.archivedAt)); setBots(nextBots.filter((bot) => !bot.archivedAt));
}) })
@ -42,10 +61,20 @@ export default function GroupSettingsScreen() {
setPending(true); setPending(true);
setError(null); setError(null);
try { 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 }; 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;
const memberIds = group.members.map((member) => member.botId).join(","); if (!isMainConversation) {
if (selected.join(",") !== memberIds) input.botIds = selected; 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); if (input.name || input.botIds) await rpc("groups/update", input);
router.back(); router.back();
} catch (err) { } catch (err) {
@ -57,6 +86,29 @@ export default function GroupSettingsScreen() {
function remove() { function remove() {
if (!groupId || !group) return; 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."), [ Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
{ text: t("Cancel"), style: "cancel" }, { 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 ( return (
<> <>
<Stack.Screen options={{ title: t("Group settings") }} /> <Stack.Screen options={{ title: t("Group settings") }} />
@ -97,34 +155,27 @@ export default function GroupSettingsScreen() {
fontSize: 16, fontSize: 16,
}} }}
/> />
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}> {!isMainConversation ? (
{t("Members")} <>
</Text> <Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
<BotMemberPicker {t("Members")}
bots={bots} </Text>
selected={selected} <BotMemberPicker
onChange={setSelected} bots={bots}
disabled={pending} selected={selected}
/> onChange={setSelected}
disabled={pending}
/>
</>
) : null}
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null} {error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
<Pressable <Pressable
onPress={() => void save()} onPress={() => void save()}
disabled={ disabled={saveDisabled}
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
}
style={{ style={{
marginTop: 24, marginTop: 24,
backgroundColor: tokens.primary, backgroundColor: tokens.primary,
opacity: opacity: saveDisabled ? 0.5 : 1,
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
? 0.5
: 1,
borderRadius: 11, borderRadius: 11,
padding: 14, padding: 14,
alignItems: "center", alignItems: "center",
@ -134,19 +185,35 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")} {pending ? t("Saving…") : t("Save")}
</Text> </Text>
</Pressable> </Pressable>
<Pressable {isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
onPress={remove} <Pressable
style={{ onPress={remove}
marginTop: 16, style={{
borderRadius: 11, marginTop: 16,
borderWidth: 1, borderRadius: 11,
borderColor: tokens.border, borderWidth: 1,
padding: 14, borderColor: tokens.border,
alignItems: "center", padding: 14,
}} alignItems: "center",
> }}
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text> >
</Pressable> <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> </ScrollView>
</> </>
); );

View File

@ -597,7 +597,21 @@ export default function Home() {
}} }}
disabled={spaceBusy} disabled={spaceBusy}
onPress={() => { 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]} style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
> >

View File

@ -147,12 +147,18 @@ describe("spaceInboxItems", () => {
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false); 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())).toBe(true);
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
}); });
it("hides deletion while a space still has content", () => { it("deduplicates same-named projects into a single project entry", () => {
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false); 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 }; | { type: "heading"; key: string; title: string; space?: InboxSpace };
export function canDeleteInboxSpace( 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[] { 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 = [ const chats = [
// A main bot is reached through its space's main conversation, not listed again. // A main bot is reached through its space's main conversation, not listed again.
...space.bots ...space.bots
.filter((chat) => chat.kind !== "main") .filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), .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[] = []; 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 }); items.push({ type: "heading", key: space.id, title: space.name, space });
} }
for (const group of groupBotsForSidebar(chats, space.botSections)) { 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 signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo");
await completeOnboarding(page, undefined, { mainConversation: true }); await completeOnboarding(page, undefined, { mainConversation: true });
await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung"); 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", () => { 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(); await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again. // 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(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"); await captureScreenshot(page, testInfo, "main-conversation-landing");
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId }); const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });

View File

@ -17,7 +17,7 @@ export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMo
return ( return (
<p <p
dir="auto" 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) => ( {captionWords(text).map((word, index) => (
<span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}> <span key={`${utterance}-${index}`} className={reducedMotion ? undefined : "voice-word-in"}>

View File

@ -90,21 +90,52 @@ export function VoiceStage({
}, []); }, []);
return ( return (
<div <section
data-testid="call-view" data-testid="call-view"
data-theme="dark" data-theme="dark"
data-phase={phase} data-phase={phase}
role="dialog"
aria-modal="true"
aria-labelledby={titleId} 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"> <h1 id={titleId} className="sr-only">
{botName} {botName}
</h1> </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 ? ( {awarenessLabel && awarenessEvent ? (
<div className="w-full max-w-md"> <div className="pointer-events-auto w-full max-w-lg">
<VoiceAwarenessStrip <VoiceAwarenessStrip
event={awarenessEvent} event={awarenessEvent}
label={awarenessLabel} label={awarenessLabel}
@ -116,7 +147,7 @@ export function VoiceStage({
<section <section
data-testid="voice-panel" 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 <VoiceAurora
botColor={botColor} botColor={botColor}
@ -125,28 +156,30 @@ export function VoiceStage({
reducedMotion={reducedMotion} reducedMotion={reducedMotion}
/> />
<div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-7 pt-10 pb-4"> <div className="relative flex items-center gap-3.5 px-4 pt-3.5 pb-2.5">
<div title={`${botName} · ${botTitle}`}> <div className="shrink-0" title={`${botName} · ${botTitle}`}>
<BotAvatar <BotAvatar
color={botColor} color={botColor}
identity={botId} identity={botId}
status={botStatus} status={botStatus}
pose={mascotPose} pose={mascotPose}
size={88} size={44}
/> />
</div> </div>
<VoiceLiveText text={subtitle} reducedMotion={reducedMotion} /> <div className="min-w-0 flex-1 text-start">
{error ? ( <VoiceLiveText text={subtitle} reducedMotion={reducedMotion} />
<p role="alert" className="text-center text-ui-body-sm font-medium text-destructive"> {error ? (
{error} <p role="alert" className="mt-1 text-ui-body-sm font-medium text-destructive">
</p> {error}
) : null} </p>
) : null}
</div>
</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 <span
aria-live="polite" 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} {phaseLabel}
</span> </span>
@ -187,39 +220,6 @@ export function VoiceStage({
</Button> </Button>
</div> </div>
</section> </section>
</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>
); );
} }

View File

@ -9,6 +9,37 @@ import {
StreamingVad, StreamingVad,
} from "@rakazo/core"; } from "@rakazo/core";
import { selectedSpaceId, withSpaceHeaders } from "./rpc.js"; 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"; export type DictationMode = "hold" | "endpoint";
@ -205,7 +236,7 @@ export class Dictation {
const rec = new Ctor(); const rec = new Ctor();
rec.continuous = true; rec.continuous = true;
rec.interimResults = true; rec.interimResults = true;
rec.lang = navigator.language || "en-US"; rec.lang = speechRecognitionLang();
rec.onresult = (event) => { rec.onresult = (event) => {
if (this.token !== mine) return; if (this.token !== mine) return;
let transcript = ""; let transcript = "";

View File

@ -1,5 +1,6 @@
import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core"; import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core";
import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js"; import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js";
import { readUiLocale } from "./ui-locale.js";
export type SpeechStatus = "idle" | "preparing" | "speaking"; export type SpeechStatus = "idle" | "preparing" | "speaking";
@ -257,15 +258,57 @@ export class Speaker {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text); const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices?.() ?? []; const voices = window.speechSynthesis.getVoices?.() ?? [];
const isGerman = /[äöüß]|(?:und|der|die|das|ist|nicht|für|mit|den|dem|des)/i.test(text); const uiLocale = readUiLocale();
const langPrefix = isGerman ? "de" : "en"; 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 = const matchedVoice =
voices.find((v) => v.lang.startsWith(langPrefix) && !v.localService) || (wantsMale &&
voices.find((v) => v.lang.startsWith(langPrefix)) || (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]; voices[0];
if (matchedVoice) utterance.voice = matchedVoice; 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({ this.set({
status: "speaking", status: "speaking",

View File

@ -97,7 +97,7 @@ import { InAppBrowser } from "../components/browser/InAppBrowser";
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
import { DualSurfaceDivider } from "../components/DualSurfaceDivider"; import { DualSurfaceDivider } from "../components/DualSurfaceDivider";
import { MainInbox } from "../components/MainInbox"; 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 { useDualSurfaceLayout } from "../hooks/useDualSurfaceLayout";
import { useSidebarResize } from "../hooks/useSidebarResize"; import { useSidebarResize } from "../hooks/useSidebarResize";
import { useWideLayout } from "../hooks/useWideLayout"; import { useWideLayout } from "../hooks/useWideLayout";
@ -394,18 +394,6 @@ export function ShellPage() {
const [screenHabit, setScreenHabit] = useState(0); const [screenHabit, setScreenHabit] = useState(0);
const wideLayout = useWideLayout(); 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); const prevPanelRef = useRef(panel);
useEffect(() => { useEffect(() => {
if (!prevPanelRef.current && panel && dualSurface.layoutMode === "stream") { if (!prevPanelRef.current && panel && dualSurface.layoutMode === "stream") {
@ -679,11 +667,13 @@ export function ShellPage() {
activeGroup?.leadBotId ?? activeGroup?.leadBotId ??
activeGroup?.members?.[0]?.botId ?? activeGroup?.members?.[0]?.botId ??
activeSpace.bots?.find((b) => b.kind === "main")?.id; activeSpace.bots?.find((b) => b.kind === "main")?.id;
return ( const found =
(leadId ? bots.find((b) => b.id === leadId) : undefined) ?? (leadId ? bots.find((b) => b.id === leadId) : undefined) ??
bots.find((b) => b.kind === "main" && b.spaceId === activeSpace.id) ?? 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, inProject,
activeSpace, activeSpace,
@ -693,16 +683,35 @@ export function ShellPage() {
bots, 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(() => { 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"); dualSurface.setLayoutMode("split");
} }
}, [inProject, dualSurface]); }, [inProject, activeSpace?.id, dualSurface]);
const activeProjectSection = activeGroup?.sectionId const activeProjectSection = activeGroup?.sectionId
? botSections.find((section) => section.id === activeGroup.sectionId) ? botSections.find((section) => section.id === activeGroup.sectionId)
: undefined; : undefined;
const showProjectTimeline = const showProjectTimeline = false;
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
const activePendingAttachments = useMemo( const activePendingAttachments = useMemo(
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id), () => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
[active?.id, groupId, inGroup, pendingAttachments], [active?.id, groupId, inGroup, pendingAttachments],
@ -1953,9 +1962,57 @@ export function ShellPage() {
status: member.status, status: member.status,
})); }));
}, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]); }, [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 encounterMode = false;
const voiceEncounterActive = encounterMode || callOpen || voiceEntry; const voiceEncounterActive = callOpen || voiceEntry;
const voiceCallBot = useMemo(() => { const voiceCallBot = useMemo(() => {
if (active) return active; if (active) return active;
if (!inGroup || !activeSnapshot) return undefined; if (!inGroup || !activeSnapshot) return undefined;
@ -1983,14 +2040,15 @@ export function ShellPage() {
useEffect(() => { useEffect(() => {
if (!voiceEntry || showProjectTimeline || callOpen) return; if (!voiceEntry || showProjectTimeline || callOpen) return;
if (!voiceCallBot) return; if (!voiceCallBot) return;
if (voiceStatus?.ready) { voiceSetupPrompted.current = false;
voiceSetupPrompted.current = false; setCallOpen(true);
setCallOpen(true); if (!voiceStatus?.ready) {
return; 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]); }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const heuteActionCount = useHeuteActionCount(groups, botsRef); const heuteActionCount = useHeuteActionCount(groups, botsRef);
@ -2005,9 +2063,15 @@ export function ShellPage() {
}); });
const resolveTranscriptMemberName = useCallback( const resolveTranscriptMemberName = useCallback(
(botId: string | undefined) => (botId: string | undefined) => {
memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined), if (botId && coordinatorBot && botId === coordinatorBot.id) {
[knownBots, transcriptMembers], return coordinatorBot.name;
}
return (
memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined)
);
},
[coordinatorBot, knownBots, transcriptMembers],
); );
const replyTargetName = activeReplyTarget const replyTargetName = activeReplyTarget
? activeReplyTarget.role === "user" ? activeReplyTarget.role === "user"
@ -2124,11 +2188,14 @@ export function ShellPage() {
[groupId, navigate, searchParams, setSearchParams], [groupId, navigate, searchParams, setSearchParams],
); );
const startVoiceEncounter = useCallback(() => { const startVoiceEncounter = useCallback(() => {
if (voiceStatus?.ready) { setCallOpen(true);
setCallOpen(true); if (!voiceStatus?.ready) {
return; void rpc.voice
.connect({ provider: "kokoro", apiKey: "local-kokoro" })
.then(() => rpc.voice.status())
.then(setVoiceStatus)
.catch(() => undefined);
} }
openSettings("voice");
}, [voiceStatus?.ready]); }, [voiceStatus?.ready]);
const closeProjectSession = useCallback(() => { const closeProjectSession = useCallback(() => {
@ -3026,24 +3093,27 @@ export function ShellPage() {
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth, width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
maxWidth: mobileSidebarOpen ? 316 : undefined, 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" mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
} ${ } ${
botsSidebarCollapsed 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)]" : ""}`} } ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
> >
<div {!botsSidebarCollapsed ? (
onPointerDown={startResizingLeft} <div
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block" onPointerDown={startResizingLeft}
/> className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
) : null}
<SidebarHeader <SidebarHeader
activityMode={activityMode} activityMode={activityMode}
onToggleActivityMode={toggleActivityMode} onToggleActivityMode={toggleActivityMode}
createMenuOpen={createMenuOpen} createMenuOpen={createMenuOpen}
onCreateMenuOpenChange={setCreateMenuOpen} onCreateMenuOpenChange={setCreateMenuOpen}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)} onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
onCollapseSidebar={() => setBotsSidebarCollapsedPref(true)}
bots={bots} bots={bots}
onCreateBot={() => setPanel("create")} onCreateBot={() => setPanel("create")}
onOpenBot={(id) => navigate(`/app/${id}`)} onOpenBot={(id) => navigate(`/app/${id}`)}
@ -3315,6 +3385,15 @@ export function ShellPage() {
name={projectSpace.name} name={projectSpace.name}
icon={projectSpace.icon} icon={projectSpace.icon}
color={projectSpace.color} 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={ hasMenu={
projectSpace.canEdit === true || projectSpace.canDelete === true projectSpace.canEdit === true || projectSpace.canDelete === true
} }
@ -3469,8 +3548,9 @@ export function ShellPage() {
data-testid="bots-sidebar-edge" data-testid="bots-sidebar-edge"
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`} aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`}
aria-pressed={!botsSidebarCollapsed} 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 ${ 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} onPointerDown={onBotsSidebarEdgePointerDown}
onPointerMove={onBotsSidebarEdgePointerMove} onPointerMove={onBotsSidebarEdgePointerMove}
@ -3545,7 +3625,7 @@ export function ShellPage() {
: showCanvasOnDesktop; : showCanvasOnDesktop;
const dividerElement = const dividerElement =
!dualSurface.isMobile && dualSurface.layoutMode === "split" ? ( !dualSurface.isMobile && showCanvasOnDesktop && dualSurface.layoutMode === "split" ? (
<DualSurfaceDivider <DualSurfaceDivider
splitRatio={dualSurface.splitRatio} splitRatio={dualSurface.splitRatio}
onResize={dualSurface.setSplitRatio} onResize={dualSurface.setSplitRatio}
@ -3641,8 +3721,13 @@ export function ShellPage() {
} }
className="app-no-drag flex min-w-0 items-center gap-3" className="app-no-drag flex min-w-0 items-center gap-3"
> >
{(!inGroup && active) || {inGroup &&
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( (inProject ||
activeGroup?.kind !== "main" ||
projectGroupMembers.length > 1) ? (
<GroupAvatar members={projectGroupMembers} size={36} className="shrink-0" />
) : (!inGroup && active) ||
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? (
<BotAvatar <BotAvatar
color={ color={
(inGroup ? coordinatorBot?.color : active?.color) ?? FALLBACK_BOT_COLOR (inGroup ? coordinatorBot?.color : active?.color) ?? FALLBACK_BOT_COLOR
@ -3653,43 +3738,73 @@ export function ShellPage() {
className="shrink-0" className="shrink-0"
/> />
) : inGroup ? ( ) : inGroup ? (
<GroupAvatar <GroupAvatar members={projectGroupMembers} size={36} className="shrink-0" />
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
size={36}
className="shrink-0"
/>
) : null} ) : null}
<div className="min-w-0 text-start flex flex-col justify-center"> <div className="min-w-0 text-start flex flex-col justify-center">
<span <div className="flex min-w-0 items-center gap-2">
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug" <span
dir="auto" className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
> dir="auto"
{inGroup && activeGroup?.kind === "main" >
? coordinatorBot?.name === "Chief" {inGroup && activeGroup?.kind === "main"
? "Projektkoordinator" ? inProject
: (coordinatorBot?.name ?? ? (activeSpace?.name ??
(activeGroup.name === "Chief" coordinatorBot?.name ??
? "Projektkoordinator" (activeGroup.name === "Chief" ||
: activeGroup.name)) activeGroup.name === "Projektkoordinator"
: inGroup ? "Projektmanager"
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) : activeGroup.name))
: (active?.name ?? t`Choose a conversation`)} : (coordinatorBot?.name ?? activeGroup.name)
</span> : inGroup
{(!inGroup && active) || ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
(inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( : (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 <OoHeaderStatus
botName={ botName={(inGroup ? coordinatorBot?.name : active?.name) ?? ""}
(inGroup
? coordinatorBot?.name === "Chief"
? "Projektkoordinator"
: coordinatorBot?.name
: active?.name) ?? ""
}
botTitle={ botTitle={
(inGroup (inGroup
? coordinatorBot?.title === "Project lead" ? coordinatorBot?.title === "Project lead" ||
? "Projektkoordinator" coordinatorBot?.title === "Projektkoordinator" ||
: (coordinatorBot?.title ?? "Projektkoordinator") coordinatorBot?.title === "Projektmanager"
? ""
: (coordinatorBot?.title ?? "")
: active?.title) ?? "" : active?.title) ?? ""
} }
botDescription={ botDescription={
@ -3709,7 +3824,7 @@ export function ShellPage() {
</span> </span>
) : inGroup && activeGroup?.kind !== "main" ? ( ) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug"> <span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Project · sessions & folders`} {t`Group conversation`}
</span> </span>
) : null} ) : null}
</div> </div>
@ -3717,7 +3832,7 @@ export function ShellPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DualSurfaceHeaderControls <DualSurfaceHeaderControls
layoutMode={dualSurface.layoutMode} layoutMode={showCanvasOnDesktop ? dualSurface.layoutMode : "stream"}
orientation={dualSurface.orientation} orientation={dualSurface.orientation}
onSelectMode={handleSelectLayoutMode} onSelectMode={handleSelectLayoutMode}
onToggleOrientation={dualSurface.toggleOrientation} onToggleOrientation={dualSurface.toggleOrientation}
@ -3753,6 +3868,15 @@ export function ShellPage() {
title={t`Agent computer`} title={t`Agent computer`}
aria-label={t`Agent computer`} aria-label={t`Agent computer`}
onClick={() => { 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. // The computer belongs to a bot; from a group, open its lead's computer.
if (!active && groupComputerBotId) { if (!active && groupComputerBotId) {
navigate(`/app/${groupComputerBotId}`); navigate(`/app/${groupComputerBotId}`);
@ -3778,7 +3902,7 @@ export function ShellPage() {
} }
}} }}
data-active={ data-active={
panel === "computer" && dualSurface.layoutMode !== "stream" (panel === "computer" || (inProject && !active)) && showCanvasOnDesktop
? "" ? ""
: undefined : undefined
} }
@ -3829,25 +3953,6 @@ export function ShellPage() {
</div> </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 ? ( {active && activeDelegation ? (
<div <div
data-testid="swarm-activity-banner" data-testid="swarm-activity-banner"

View File

@ -1,6 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts"; import type { VoiceCredential, VoiceInfo, VoiceStatus } from "@rakazo/contracts";
import { voiceIdForLocale, voicesForLocale } from "@rakazo/core"; import { GERMAN_VOICE_PRESETS, voiceIdForLocale, voicesForLocale } from "@rakazo/core";
import { import {
Button, Button,
cn, cn,
@ -113,12 +113,19 @@ export function VoiceSettingsOverlay({
}, [activeBotId]); }, [activeBotId]);
const voiceOptions = useMemo(() => { 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 (listed.length) return listed;
if (isGermanLocale) return GERMAN_VOICE_PRESETS;
return voiceId ? [{ id: voiceId, label: voiceId }] : []; return voiceId ? [{ id: voiceId, label: voiceId }] : [];
}, [voices, voiceId, uiLocale]); }, [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> { async function ensureLocalVoice(): Promise<boolean> {
if (localConnected) return true; if (localConnected) return true;
@ -133,6 +140,9 @@ export function VoiceSettingsOverlay({
await refresh(); await refresh();
return true; return true;
} catch (err) { } catch (err) {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
return true;
}
setError(err instanceof Error ? err.message : t`Could not enable speaking`); setError(err instanceof Error ? err.message : t`Could not enable speaking`);
return false; return false;
} finally { } finally {
@ -142,7 +152,12 @@ export function VoiceSettingsOverlay({
async function chooseVoice(nextVoiceId: string) { async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId); setVoiceId(nextVoiceId);
if (!localConnected) return; if (!localConnected) {
if (activeBotId) {
await rpc.bots.update({ botId: activeBotId, voiceId: nextVoiceId }).catch(() => undefined);
}
return;
}
markPending("voice"); markPending("voice");
setError(null); setError(null);
try { try {
@ -161,6 +176,7 @@ export function VoiceSettingsOverlay({
setError(null); setError(null);
setNotice(null); setNotice(null);
try { try {
let chosenVoice = voiceId;
if (next) { if (next) {
const connected = await ensureLocalVoice(); const connected = await ensureLocalVoice();
if (!connected) { if (!connected) {
@ -168,16 +184,29 @@ export function VoiceSettingsOverlay({
return; return;
} }
const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); 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) { if (nextVoice) {
await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }); chosenVoice = nextVoice;
setVoiceId(nextVoice); setVoiceId(nextVoice);
await rpc.voice
.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID })
.catch(() => undefined);
} }
} }
if (activeBotId) { if (activeBotId) {
await rpc.bots.update({ botId: activeBotId, autoSpeak: next }); await rpc.bots.update({
botId: activeBotId,
autoSpeak: next,
...(chosenVoice ? { voiceId: chosenVoice } : {}),
});
} }
await refresh(); await refresh();
if (!voiceId && chosenVoice) setVoiceId(chosenVoice);
setNotice(next ? t`Speaking is on.` : t`Speaking is off.`); setNotice(next ? t`Speaking is on.` : t`Speaking is off.`);
} catch (err) { } catch (err) {
setAutoSpeak(!next); setAutoSpeak(!next);
@ -193,7 +222,11 @@ export function VoiceSettingsOverlay({
return; return;
} }
setError(null); 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, voiceId: voice.id,
messageId: `${PREVIEW_PREFIX}${voice.id}`, messageId: `${PREVIEW_PREFIX}${voice.id}`,
}); });
@ -234,7 +267,7 @@ export function VoiceSettingsOverlay({
{autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>} {autoSpeak ? <Trans>Can speak</Trans> : <Trans>Does not speak</Trans>}
</p> </p>
<p className="mt-1 text-ui-body-sm text-muted-foreground"> <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> </p>
</div> </div>
<Switch <Switch

View File

@ -1,16 +1,19 @@
import { useLingui } from "@lingui/react/macro"; 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 { MoreHorizontal } from "lucide-react";
import { ProjectIcon } from "../../components/project-icon"; import { ProjectIcon } from "../../components/project-icon";
import { rosterNameClass, rosterRowSurfaceClass } from "./roster-row-style"; import { rosterNameClass, rosterRowSurfaceClass } from "./roster-row-style";
import { FALLBACK_BOT_COLOR } from "./shell-types";
import type { SpaceMenuRequest } from "./sidebar-group-header"; 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({ export function ProjectRow({
spaceId, spaceId,
name, name,
icon, icon,
color, color,
members,
hasMenu, hasMenu,
selected, selected,
unread, unread,
@ -23,6 +26,7 @@ export function ProjectRow({
name: string; name: string;
icon?: string | null; icon?: string | null;
color?: string | null; color?: string | null;
members?: GroupAvatarMember[];
hasMenu: boolean; hasMenu: boolean;
selected: boolean; selected: boolean;
unread: boolean; unread: boolean;
@ -58,6 +62,27 @@ export function ProjectRow({
> >
{name} {name}
</span> </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 ? ( {needsYou > 0 ? (
<span <span
aria-hidden="true" aria-hidden="true"

View File

@ -1,7 +1,7 @@
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import type { Bot } from "@rakazo/contracts"; import type { Bot } from "@rakazo/contracts";
import { Popover, PopoverContent, PopoverTrigger, Wordmark } from "@rakazo/ui-web"; 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 { WindowChrome } from "../WindowChrome";
import { BotCreatePicker } from "./bot-picker"; import { BotCreatePicker } from "./bot-picker";
@ -12,6 +12,7 @@ export function SidebarHeader({
createMenuOpen, createMenuOpen,
onCreateMenuOpenChange, onCreateMenuOpenChange,
onCloseMobileSidebar, onCloseMobileSidebar,
onCollapseSidebar,
bots, bots,
onCreateBot, onCreateBot,
onOpenBot, onOpenBot,
@ -25,6 +26,7 @@ export function SidebarHeader({
createMenuOpen: boolean; createMenuOpen: boolean;
onCreateMenuOpenChange: (open: boolean) => void; onCreateMenuOpenChange: (open: boolean) => void;
onCloseMobileSidebar: () => void; onCloseMobileSidebar: () => void;
onCollapseSidebar?: () => void;
bots: Bot[]; bots: Bot[];
onCreateBot: () => void; onCreateBot: () => void;
onOpenBot: (id: string) => void; onOpenBot: (id: string) => void;
@ -40,7 +42,7 @@ export function SidebarHeader({
<div className="flex-1 px-1"> <div className="flex-1 px-1">
<Wordmark /> <Wordmark />
</div> </div>
<div className="relative flex items-center gap-2.5"> <div className="relative flex items-center gap-1.5">
<button <button
type="button" type="button"
aria-label={t`Activity`} aria-label={t`Activity`}
@ -113,6 +115,28 @@ export function SidebarHeader({
</PopoverContent> </PopoverContent>
) : null} ) : null}
</Popover> </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>
</div> </div>
); );

View File

@ -455,7 +455,13 @@ export const Transcript = memo(function Transcript({
}`} }`}
> >
{reactionView.visibleMessages.map((message) => { {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; if (runNarration.hidden.has(message.id)) return null;
const foldedSteps = runNarration.steps.get(message.id); const foldedSteps = runNarration.steps.get(message.id);
const peerReceipt = isPeerReceiptBlocks(message.blocks); const peerReceipt = isPeerReceiptBlocks(message.blocks);
@ -628,7 +634,7 @@ export const Transcript = memo(function Transcript({
</div> </div>
); );
})} })}
{running && !encounterMode ? ( {(running || messages.some((m) => m.id.startsWith("progress:"))) && !encounterMode ? (
<div className="mt-2.5"> <div className="mt-2.5">
<JobProgressCard <JobProgressCard
bot={workingBots[0]} bot={workingBots[0]}
@ -924,7 +930,7 @@ const MessageView = memo(function MessageView({
Boolean(foldedSteps && foldedSteps.length > 0) || Boolean(foldedSteps && foldedSteps.length > 0) ||
message.blocks.some((b) => b.kind === "steps"); message.blocks.some((b) => b.kind === "steps");
const visibleNarrationBlocks = message.blocks const visibleNarrationBlocks = message.blocks
.filter((block) => !isToolActivityBlock(block)) .filter((block) => !isToolActivityBlock(block) && block.kind !== "progress")
.filter((block) => { .filter((block) => {
// Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message // Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message
if (hasSteps && block.kind === "text") { if (hasSteps && block.kind === "text") {
@ -944,14 +950,18 @@ const MessageView = memo(function MessageView({
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null); const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
const messageContext = ( const messageContext = (
<> <>
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? ( {effectiveSpeakerName && message.role === "bot" ? (
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5"> <button
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs"> 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 <BotAvatar
color={speakerBot?.color || speakerColorDef.hex} color={speakerBot?.color || speakerColorDef.hex}
identity={message.botId ?? "bot"} identity={message.botId ?? "bot"}
size={22} size={28}
moment={isLive ? "working" : "idle"} moment={isLive ? "working" : "idle"}
/> />
</div> </div>
@ -966,7 +976,7 @@ const MessageView = memo(function MessageView({
Oo Oo
</span> </span>
</div> </div>
</div> </button>
<span className="text-ui-label font-mono text-muted-foreground tabular-nums"> <span className="text-ui-label font-mono text-muted-foreground tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], { {new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@ -974,16 +984,24 @@ const MessageView = memo(function MessageView({
})} })}
</span> </span>
</div> </div>
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? ( ) : message.role === "user" && !encounterMode ? (
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1.5 flex items-center justify-end gap-2">
<span dir="auto">{effectiveSpeakerName}</span> <span className="text-ui-label font-mono text-muted-foreground tabular-nums">
<span aria-hidden>·</span>
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
{new Date(message.createdAt).toLocaleTimeString([], { {new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "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> </div>
) : speakerName ? ( ) : speakerName ? (
<div <div
@ -994,7 +1012,7 @@ const MessageView = memo(function MessageView({
<BotAvatar <BotAvatar
color={speakerBot?.color ?? FALLBACK_BOT_COLOR} color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
identity={message.botId} identity={message.botId}
size={22} size={24}
/> />
{speakerName} {speakerName}
</div> </div>
@ -1025,18 +1043,14 @@ const MessageView = memo(function MessageView({
<div className="flex w-fit max-w-full justify-start"> <div className="flex w-fit max-w-full justify-start">
<div <div
data-testid="message-bot-bubble" data-testid="message-bot-bubble"
className={ 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"
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"
}
dir="auto" dir="auto"
> >
{visibleNarrationBlocks.map((block, i) => { {visibleNarrationBlocks.map((block, i) => {
if (block.kind === "text" || block.kind === "progress") { if (block.kind === "text") {
return ( return (
<div key={i}> <div key={i}>
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown> <ChatMarkdown>{block.text}</ChatMarkdown>
</div> </div>
); );
} }
@ -1147,17 +1161,7 @@ const MessageView = memo(function MessageView({
); );
} }
if (block.kind === "progress") { if (block.kind === "progress") {
return ( return null;
<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>
);
} }
if (block.kind === "subagent") { if (block.kind === "subagent") {
const running = block.status === "running"; const running = block.status === "running";
@ -1196,11 +1200,9 @@ const MessageView = memo(function MessageView({
</span> </span>
</div> </div>
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</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"> <div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown streaming={running}> <ChatMarkdown>{block.result}</ChatMarkdown>
{block.result || block.progress || ""}
</ChatMarkdown>
</div> </div>
) : null} ) : null}
</div> </div>
@ -1349,9 +1351,7 @@ const MessageView = memo(function MessageView({
<div <div
data-testid="message-user-bubble" data-testid="message-user-bubble"
className={cn( className={cn(
encounterMode "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",
? "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",
isOptimistic && "opacity-80", isOptimistic && "opacity-80",
)} )}
dir="auto" dir="auto"
@ -1373,11 +1373,7 @@ const MessageView = memo(function MessageView({
<div key={i} className="flex w-fit max-w-full justify-start"> <div key={i} className="flex w-fit max-w-full justify-start">
<div <div
data-testid="message-bot-bubble" data-testid="message-bot-bubble"
className={ 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"
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"
}
dir="auto" dir="auto"
> >
<ChatMarkdown>{block.text}</ChatMarkdown> <ChatMarkdown>{block.text}</ChatMarkdown>

View File

@ -149,6 +149,7 @@ export function useSidebarModel({
if (!homeSpaceId) return []; if (!homeSpaceId) return [];
const entries: typeof sidebarGroups = []; const entries: typeof sidebarGroups = [];
const seenProjectSpaceIds = new Set<string>(); const seenProjectSpaceIds = new Set<string>();
const seenProjectNames = new Set<string>();
const seenProjectGroupChatIds = new Set<string>(); const seenProjectGroupChatIds = new Set<string>();
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => { 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) { 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); seenProjectSpaceIds.add(space.id);
const groupsInSpace = sidebarGroups.flatMap((group) => const groupsInSpace = sidebarGroups.flatMap((group) =>
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [], group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [],
@ -192,6 +214,8 @@ export function useSidebarModel({
const groupChats = dedupeSidebarBots( const groupChats = dedupeSidebarBots(
group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"), group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"),
).filter((item) => { ).filter((item) => {
const normalized = item.chat.name.trim().toLowerCase();
if (normalized && seenProjectNames.has(normalized)) return false;
if (seenProjectGroupChatIds.has(item.chat.id)) return false; if (seenProjectGroupChatIds.has(item.chat.id)) return false;
seenProjectGroupChatIds.add(item.chat.id); seenProjectGroupChatIds.add(item.chat.id);
return true; return true;

View File

@ -71,6 +71,25 @@ export function formatWorkingPrinciples(toolNames: ReadonlySet<string>): string
export const ROUTINE_RUN_INSTRUCTION = 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."; "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 * 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 * neither denies a connected app nor claims one whose tools failed to load. Apps reached

View File

@ -253,6 +253,7 @@ import {
formatMainAgentRole, formatMainAgentRole,
formatSubagentContext, formatSubagentContext,
formatUserContext, formatUserContext,
formatVoiceConversationInstruction,
formatWorkingPrinciples, formatWorkingPrinciples,
ONBOARDING_OPEN_INSTRUCTION, ONBOARDING_OPEN_INSTRUCTION,
ROUTINE_RUN_INSTRUCTION, ROUTINE_RUN_INSTRUCTION,
@ -4127,6 +4128,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
instructions: [ instructions: [
formatBotIdentity(bot), formatBotIdentity(bot),
formatWorkingPrinciples(exposedToolNames), formatWorkingPrinciples(exposedToolNames),
formatVoiceConversationInstruction({
autoSpeak: Boolean(bot.autoSpeak),
}),
bot.instructions, bot.instructions,
bot.kind === "main" bot.kind === "main"
? formatMainAgentRole(exposedToolNames, mainAgentProject) ? 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("I'm sure that's fine, go look at the logs")).toBeNull();
expect(spokenDecision("")).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 { sanitizeBotReplyText } from "./bot-reply-sanitize.js";
import { isToolActivityBlock } from "./tool-activity.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. */ /** 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 = const lang =
fence fence
.trim() .trim()
@ -34,6 +41,9 @@ function describeCodeBlock(fence: string): string {
css: "CSS", css: "CSS",
}; };
const name = spoken[lang.toLowerCase()]; const name = spoken[lang.toLowerCase()];
if (german) {
return name ? `. (ein ${name} Codeblock) ` : ". (ein Codeblock) ";
}
return name ? `. (a ${name} code block) ` : ". (a code block) "; return name ? `. (a ${name} code block) ` : ". (a code block) ";
} }
@ -54,17 +64,24 @@ export function speakable(input: string): string {
if (!input) return ""; if (!input) return "";
let text = sanitizeBotReplyText(input).text; let text = sanitizeBotReplyText(input).text;
if (!text) return ""; if (!text) return "";
const german = isGermanText(text);
text = text.replace(/(`{3,}|~{3,})([^\n]*)\n[\s\S]*?(?:\1|$)/g, (_m, _marker, fence: string) => 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) => 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(/\[([^\]]+)\]\([^)]*\)/g, "$1");
text = text.replace(/<https?:\/\/[^>\s]+>/g, " a link "); text = text.replace(/<https?:\/\/[^>\s]+>/g, german ? " ein Link " : " a link ");
text = text.replace(/\bhttps?:\/\/\S+/g, " 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:-]*\|[\s|:-]*$/gm, "");
text = text.replace(/^\s*\|(.+)\|\s*$/gm, (_m, row: string) => 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) => 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) => 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 = 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. * Split speakable text into utterances a synthesizer can start on.
@ -263,8 +280,10 @@ export function speechFromBlocks(blocks: MessageBlock[]): string {
.join("\n"); .join("\n");
} }
const YES = /^(yes|yeah|yep|yup|ok|okay|sure|do it|go ahead|approve|confirmed|affirmative)\b/i; const YES =
const NO = /^(no|nope|nah|cancel|stop|don't|do not|reject|deny|negative)\b/i; /^(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". */ /** Consent must never be inferred from a sentence that merely contained "sure". */
export function spokenDecision(transcript: string): "yes" | "no" | null { 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" }); ).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"); const stranger = await newUser("Eve");
await expect( await expect(
updateSpaceForOwner(prisma, { userId: stranger.userId, spaceId: project.id, name: "Mine" }), 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. */ /** Name and role of a project's main bot, the one the user talks to about that project. */
export const PROJECT_MAIN_BOT_DEFAULTS = { export const PROJECT_MAIN_BOT_DEFAULTS = {
name: "Chief", name: "Projektmanager",
title: "Project lead", title: "Project lead",
description: "Leads this project and hands its work to specialists.", description: "Leads this project and hands its work to specialists.",
} as const; } as const;
@ -114,13 +114,13 @@ export async function ensureMainBot(
spaceId?: string, spaceId?: string,
): Promise<MainBotRef> { ): Promise<MainBotRef> {
const actor = await requireMembership(prisma, userId, spaceId); 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); let main = await findMainBot(prisma, userId, actor.spaceId);
if (!main) { 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 const first = home
? await prisma.bot.findFirst({ ? await prisma.bot.findFirst({
where: { where: {
@ -161,8 +161,16 @@ export async function ensureMainBot(
main = await findMainBot(prisma, userId, actor.spaceId); main = await findMainBot(prisma, userId, actor.spaceId);
if (!main) throw new IsolationError("Main bot could not be created"); if (!main) throw new IsolationError("Main bot could not be created");
} }
const botUpdates: { archivedAt?: null; name?: string } = {};
if (main.archivedAt) { 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"); 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 }; 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" }, where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
select: { select: {
id: true, id: true,
name: true,
spaceId: true, spaceId: true,
leadBotId: true, leadBotId: true,
archivedAt: true, archivedAt: true,
@ -222,10 +231,20 @@ export async function ensureMainConversation(
} }
group = await findMainConversation(prisma, key); group = await findMainConversation(prisma, key);
} else { } 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.leadBotId !== main.id) repairs.leadBotId = main.id;
if (group.archivedAt) repairs.archivedAt = null; 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 }); await prisma.chatGroup.update({ where: { id: group.id }, data: repairs });
} }
if (group.members.length === 0) { 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 { export class SpaceNotFoundError extends Error {
constructor() { constructor() {
super("Space not found"); super("Space not found");
@ -210,10 +217,28 @@ export async function updateSpaceForOwner(
if (name !== undefined && (!name || name.length > 60)) throw new InvalidSpaceNameError(); if (name !== undefined && (!name || name.length > 60)) throw new InvalidSpaceNameError();
const membership = await prisma.spaceMember.findUnique({ const membership = await prisma.spaceMember.findUnique({
where: { spaceId_userId: { spaceId: input.spaceId, userId: input.userId } }, 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 || membership.space.deletingAt) throw new SpaceNotFoundError();
if (membership.role !== "owner") throw new IsolationError(); 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({ await prisma.space.update({
where: { id: input.spaceId }, where: { id: input.spaceId },
data: { data: {
@ -399,9 +424,7 @@ async function assertEmptySpaceDeletable(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
if (memberships.length <= 1) throw new CannotDeleteLastSpaceError(); if (memberships.length <= 1) throw new CannotDeleteLastSpaceError();
const exceptOwnMain = options.exceptOwnMain const exceptOwnMain = options.exceptOwnMain ? { NOT: { userId: input.userId } } : {};
? { NOT: { userId: input.userId, kind: "main" } }
: {};
const [botCount, groupCount, computers] = await Promise.all([ const [botCount, groupCount, computers] = await Promise.all([
db.bot.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }), db.bot.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
db.chatGroup.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 * The deleting user's own bots and conversations in a Space that holds no other member's content
* and passes every other deletion check. A project's main agent is part of the project, so the * and passes every other deletion check. The caller removes these before claiming the Space;
* caller removes these before claiming the Space; nothing is returned while other content or * nothing is returned while another member's content or another rule would still block deletion.
* another rule would still block the deletion.
*/ */
export async function ownMainAgentsBlockingSpaceDeletion( export async function ownMainAgentsBlockingSpaceDeletion(
prisma: PrismaClient, prisma: PrismaClient,
input: EmptySpaceDeleteInput, input: EmptySpaceDeleteInput,
): Promise<{ botIds: string[]; groupIds: string[] }> { ): Promise<{ botIds: string[]; groupIds: string[] }> {
await assertEmptySpaceDeletable(prisma, input, { exceptOwnMain: true }); 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([ const [bots, groups] = await Promise.all([
prisma.bot.findMany({ where: own, select: { id: true } }), prisma.bot.findMany({ where: own, select: { id: true } }),
prisma.chatGroup.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); 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 cookie = await signup(app, `space-delete-${stamp}@rakazo.test`, "Space Delete");
const actor = await rpc<Actor>(app, cookie, "me"); const actor = await rpc<Actor>(app, cookie, "me");
const empty = await rpc<Space>(app, cookie, "spaces/create", { name: "Temporary" }); const empty = await rpc<Space>(app, cookie, "spaces/create", { name: "Temporary" });
const busy = await rpc<Space>(app, cookie, "spaces/create", { name: "Busy" }); 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); 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); await rpc(app, cookie, "bots/archive", { botId: busyBot.id }, busy.id);
expect( expect(
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, busy.id)).spaces.find( (await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, busy.id)).spaces.find(
(space) => space.id === busy.id, (space) => space.id === busy.id,
), ),
).toMatchObject({ hasContent: true, canDelete: false }); ).toMatchObject({ hasContent: true, canDelete: true });
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
status: 400,
});
await expect( await expect(
raw(app, cookie, "spaces/remove", { spaceId: actor.spaceId }), raw(app, cookie, "spaces/remove", { spaceId: actor.spaceId }),
).resolves.toMatchObject({ status: 400 }); ).resolves.toMatchObject({ status: 400 });
@ -1727,6 +1718,16 @@ describeWithDatabase("API authorization and resource isolation", () => {
status: 404, 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. // Shared-space members must not delete; only the SpaceMember owner may.
const shared = await rpc<Space>(app, cookie, "spaces/create", { name: "Shared empty" }); const shared = await rpc<Space>(app, cookie, "spaces/create", { name: "Shared empty" });
expect(shared.canDelete).toBe(true); expect(shared.canDelete).toBe(true);
@ -1767,12 +1768,12 @@ describeWithDatabase("API authorization and resource isolation", () => {
handles.prisma.space.findUnique({ where: { id: shared.id } }), handles.prisma.space.findUnique({ where: { id: shared.id } }),
).resolves.not.toBeNull(); ).resolves.not.toBeNull();
// Another member's bot still counts as content for onboarding emptiness. // Another member's bot counts as content and blocks the owner from deleting the shared space.
const ownerBot = await rpc<Bot>( const memberBot = await rpc<Bot>(
app, app,
cookie, memberCookie,
"bots/create", "bots/create",
botInput("Owner shared bot"), botInput("Member shared bot"),
shared.id, shared.id,
); );
const ownerNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, 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 }), 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>( const memberNavigation = await rpc<SpaceNavigation>(
app, app,
memberCookie, 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( expect(
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id)).spaces.find( (await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id)).spaces.find(
(space) => space.id === shared.id, (space) => space.id === shared.id,