feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX
This commit is contained in:
parent
1611fe5fa2
commit
f6053dd2a8
@ -135,6 +135,7 @@ import {
|
|||||||
createProjectGoalRepos,
|
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.
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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]}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 " });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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"}>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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" }),
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 } }),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user